Changelog: https://github.com/caronc/apprise/releases Highlights: * v1.6.0 * Notifiarr * v1.5.0 * Pushy * PushDeer * PushMe * RSyslog * v1.4.5 * WhatsApp * Burst SMSpull/2315/head
parent
cb2023d94e
commit
55c5384f9c
@ -0,0 +1,33 @@
|
|||||||
|
# This is a stub package designed to roughly emulate the _yaml
|
||||||
|
# extension module, which previously existed as a standalone module
|
||||||
|
# and has been moved into the `yaml` package namespace.
|
||||||
|
# It does not perfectly mimic its old counterpart, but should get
|
||||||
|
# close enough for anyone who's relying on it even when they shouldn't.
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
# in some circumstances, the yaml module we imoprted may be from a different version, so we need
|
||||||
|
# to tread carefully when poking at it here (it may not have the attributes we expect)
|
||||||
|
if not getattr(yaml, '__with_libyaml__', False):
|
||||||
|
from sys import version_info
|
||||||
|
|
||||||
|
exc = ModuleNotFoundError if version_info >= (3, 6) else ImportError
|
||||||
|
raise exc("No module named '_yaml'")
|
||||||
|
else:
|
||||||
|
from yaml._yaml import *
|
||||||
|
import warnings
|
||||||
|
warnings.warn(
|
||||||
|
'The _yaml extension module is now located at yaml._yaml'
|
||||||
|
' and its location is subject to change. To use the'
|
||||||
|
' LibYAML-based parser and emitter, import from `yaml`:'
|
||||||
|
' `from yaml import CLoader as Loader, CDumper as Dumper`.',
|
||||||
|
DeprecationWarning
|
||||||
|
)
|
||||||
|
del warnings
|
||||||
|
# Don't `del yaml` here because yaml is actually an existing
|
||||||
|
# namespace member of _yaml.
|
||||||
|
|
||||||
|
__name__ = '_yaml'
|
||||||
|
# If the module is top-level (i.e. not a part of any specific package)
|
||||||
|
# then the attribute should be set to ''.
|
||||||
|
# https://docs.python.org/3.8/library/types.html
|
||||||
|
__package__ = ''
|
Binary file not shown.
@ -0,0 +1,460 @@
|
|||||||
|
# -*- 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.
|
||||||
|
|
||||||
|
# Sign-up with https://burstsms.com/
|
||||||
|
#
|
||||||
|
# Define your API Secret here and acquire your API Key
|
||||||
|
# - https://can.transmitsms.com/profile
|
||||||
|
#
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .NotifyBase import NotifyBase
|
||||||
|
from ..URLBase import PrivacyMode
|
||||||
|
from ..common import NotifyType
|
||||||
|
from ..utils import is_phone_no
|
||||||
|
from ..utils import parse_phone_no
|
||||||
|
from ..utils import parse_bool
|
||||||
|
from ..utils import validate_regex
|
||||||
|
from ..AppriseLocale import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class BurstSMSCountryCode:
|
||||||
|
# Australia
|
||||||
|
AU = 'au'
|
||||||
|
# New Zeland
|
||||||
|
NZ = 'nz'
|
||||||
|
# United Kingdom
|
||||||
|
UK = 'gb'
|
||||||
|
# United States
|
||||||
|
US = 'us'
|
||||||
|
|
||||||
|
|
||||||
|
BURST_SMS_COUNTRY_CODES = (
|
||||||
|
BurstSMSCountryCode.AU,
|
||||||
|
BurstSMSCountryCode.NZ,
|
||||||
|
BurstSMSCountryCode.UK,
|
||||||
|
BurstSMSCountryCode.US,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NotifyBurstSMS(NotifyBase):
|
||||||
|
"""
|
||||||
|
A wrapper for Burst SMS Notifications
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The default descriptive name associated with the Notification
|
||||||
|
service_name = 'Burst SMS'
|
||||||
|
|
||||||
|
# The services URL
|
||||||
|
service_url = 'https://burstsms.com/'
|
||||||
|
|
||||||
|
# The default protocol
|
||||||
|
secure_protocol = 'burstsms'
|
||||||
|
|
||||||
|
# The maximum amount of SMS Messages that can reside within a single
|
||||||
|
# batch transfer based on:
|
||||||
|
# https://developer.transmitsms.com/#74911cf8-dec6-4319-a499-7f535a7fd08c
|
||||||
|
default_batch_size = 500
|
||||||
|
|
||||||
|
# A URL that takes you to the setup/help of the specific protocol
|
||||||
|
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_burst_sms'
|
||||||
|
|
||||||
|
# Burst SMS uses the http protocol with JSON requests
|
||||||
|
notify_url = 'https://api.transmitsms.com/send-sms.json'
|
||||||
|
|
||||||
|
# The maximum length of the body
|
||||||
|
body_maxlen = 160
|
||||||
|
|
||||||
|
# A title can not be used for SMS Messages. Setting this to zero will
|
||||||
|
# cause any title (if defined) to get placed into the message body.
|
||||||
|
title_maxlen = 0
|
||||||
|
|
||||||
|
# Define object templates
|
||||||
|
templates = (
|
||||||
|
'{schema}://{apikey}:{secret}@{sender_id}/{targets}',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define our template tokens
|
||||||
|
template_tokens = dict(NotifyBase.template_tokens, **{
|
||||||
|
'apikey': {
|
||||||
|
'name': _('API Key'),
|
||||||
|
'type': 'string',
|
||||||
|
'required': True,
|
||||||
|
'regex': (r'^[a-z0-9]+$', 'i'),
|
||||||
|
'private': True,
|
||||||
|
},
|
||||||
|
'secret': {
|
||||||
|
'name': _('API Secret'),
|
||||||
|
'type': 'string',
|
||||||
|
'private': True,
|
||||||
|
'required': True,
|
||||||
|
'regex': (r'^[a-z0-9]+$', 'i'),
|
||||||
|
},
|
||||||
|
'sender_id': {
|
||||||
|
'name': _('Sender ID'),
|
||||||
|
'type': 'string',
|
||||||
|
'required': True,
|
||||||
|
'map_to': 'source',
|
||||||
|
},
|
||||||
|
'target_phone': {
|
||||||
|
'name': _('Target Phone No'),
|
||||||
|
'type': 'string',
|
||||||
|
'prefix': '+',
|
||||||
|
'regex': (r'^[0-9\s)(+-]+$', 'i'),
|
||||||
|
'map_to': 'targets',
|
||||||
|
},
|
||||||
|
'targets': {
|
||||||
|
'name': _('Targets'),
|
||||||
|
'type': 'list:string',
|
||||||
|
'required': True,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
# Define our template arguments
|
||||||
|
template_args = dict(NotifyBase.template_args, **{
|
||||||
|
'to': {
|
||||||
|
'alias_of': 'targets',
|
||||||
|
},
|
||||||
|
'from': {
|
||||||
|
'alias_of': 'sender_id',
|
||||||
|
},
|
||||||
|
'key': {
|
||||||
|
'alias_of': 'apikey',
|
||||||
|
},
|
||||||
|
'secret': {
|
||||||
|
'alias_of': 'secret',
|
||||||
|
},
|
||||||
|
'country': {
|
||||||
|
'name': _('Country'),
|
||||||
|
'type': 'choice:string',
|
||||||
|
'values': BURST_SMS_COUNTRY_CODES,
|
||||||
|
'default': BurstSMSCountryCode.US,
|
||||||
|
},
|
||||||
|
# Validity
|
||||||
|
# Expire a message send if it is undeliverable (defined in minutes)
|
||||||
|
# If set to Zero (0); this is the default and sets the max validity
|
||||||
|
# period
|
||||||
|
'validity': {
|
||||||
|
'name': _('validity'),
|
||||||
|
'type': 'int',
|
||||||
|
'default': 0
|
||||||
|
},
|
||||||
|
'batch': {
|
||||||
|
'name': _('Batch Mode'),
|
||||||
|
'type': 'bool',
|
||||||
|
'default': False,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
def __init__(self, apikey, secret, source, targets=None, country=None,
|
||||||
|
validity=None, batch=None, **kwargs):
|
||||||
|
"""
|
||||||
|
Initialize Burst SMS Object
|
||||||
|
"""
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
# API Key (associated with project)
|
||||||
|
self.apikey = validate_regex(
|
||||||
|
apikey, *self.template_tokens['apikey']['regex'])
|
||||||
|
if not self.apikey:
|
||||||
|
msg = 'An invalid Burst SMS API Key ' \
|
||||||
|
'({}) was specified.'.format(apikey)
|
||||||
|
self.logger.warning(msg)
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
# API Secret (associated with project)
|
||||||
|
self.secret = validate_regex(
|
||||||
|
secret, *self.template_tokens['secret']['regex'])
|
||||||
|
if not self.secret:
|
||||||
|
msg = 'An invalid Burst SMS API Secret ' \
|
||||||
|
'({}) was specified.'.format(secret)
|
||||||
|
self.logger.warning(msg)
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
if not country:
|
||||||
|
self.country = self.template_args['country']['default']
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.country = country.lower().strip()
|
||||||
|
if country not in BURST_SMS_COUNTRY_CODES:
|
||||||
|
msg = 'An invalid Burst SMS country ' \
|
||||||
|
'({}) was specified.'.format(country)
|
||||||
|
self.logger.warning(msg)
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
# Set our Validity
|
||||||
|
self.validity = self.template_args['validity']['default']
|
||||||
|
if validity:
|
||||||
|
try:
|
||||||
|
self.validity = int(validity)
|
||||||
|
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
msg = 'The Burst SMS Validity specified ({}) is invalid.'\
|
||||||
|
.format(validity)
|
||||||
|
self.logger.warning(msg)
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
# Prepare Batch Mode Flag
|
||||||
|
self.batch = self.template_args['batch']['default'] \
|
||||||
|
if batch is None else batch
|
||||||
|
|
||||||
|
# The Sender ID
|
||||||
|
self.source = validate_regex(source)
|
||||||
|
if not self.source:
|
||||||
|
msg = 'The Account Sender ID specified ' \
|
||||||
|
'({}) is invalid.'.format(source)
|
||||||
|
self.logger.warning(msg)
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
# Parse our targets
|
||||||
|
self.targets = list()
|
||||||
|
|
||||||
|
for target in parse_phone_no(targets):
|
||||||
|
# Validate targets and drop bad ones:
|
||||||
|
result = is_phone_no(target)
|
||||||
|
if not result:
|
||||||
|
self.logger.warning(
|
||||||
|
'Dropped invalid phone # '
|
||||||
|
'({}) specified.'.format(target),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# store valid phone number
|
||||||
|
self.targets.append(result['full'])
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||||
|
"""
|
||||||
|
Perform Burst SMS Notification
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not self.targets:
|
||||||
|
self.logger.warning(
|
||||||
|
'There are no valid Burst SMS targets to notify.')
|
||||||
|
return False
|
||||||
|
|
||||||
|
# error tracking (used for function return)
|
||||||
|
has_error = False
|
||||||
|
|
||||||
|
# Prepare our headers
|
||||||
|
headers = {
|
||||||
|
'User-Agent': self.app_id,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prepare our authentication
|
||||||
|
auth = (self.apikey, self.secret)
|
||||||
|
|
||||||
|
# Prepare our payload
|
||||||
|
payload = {
|
||||||
|
'countrycode': self.country,
|
||||||
|
'message': body,
|
||||||
|
|
||||||
|
# Sender ID
|
||||||
|
'from': self.source,
|
||||||
|
|
||||||
|
# The to gets populated in the loop below
|
||||||
|
'to': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send in batches if identified to do so
|
||||||
|
batch_size = 1 if not self.batch else self.default_batch_size
|
||||||
|
|
||||||
|
# Create a copy of the targets list
|
||||||
|
targets = list(self.targets)
|
||||||
|
|
||||||
|
for index in range(0, len(targets), batch_size):
|
||||||
|
|
||||||
|
# Prepare our user
|
||||||
|
payload['to'] = ','.join(self.targets[index:index + batch_size])
|
||||||
|
|
||||||
|
# Some Debug Logging
|
||||||
|
self.logger.debug('Burst SMS POST URL: {} (cert_verify={})'.format(
|
||||||
|
self.notify_url, self.verify_certificate))
|
||||||
|
self.logger.debug('Burst SMS Payload: {}' .format(payload))
|
||||||
|
|
||||||
|
# Always call throttle before any remote server i/o is made
|
||||||
|
self.throttle()
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
self.notify_url,
|
||||||
|
data=payload,
|
||||||
|
headers=headers,
|
||||||
|
auth=auth,
|
||||||
|
verify=self.verify_certificate,
|
||||||
|
timeout=self.request_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
if r.status_code != requests.codes.ok:
|
||||||
|
# We had a problem
|
||||||
|
status_str = \
|
||||||
|
NotifyBurstSMS.http_response_code_lookup(
|
||||||
|
r.status_code)
|
||||||
|
|
||||||
|
self.logger.warning(
|
||||||
|
'Failed to send Burst SMS notification to {} '
|
||||||
|
'target(s): {}{}error={}.'.format(
|
||||||
|
len(self.targets[index:index + batch_size]),
|
||||||
|
status_str,
|
||||||
|
', ' if status_str else '',
|
||||||
|
r.status_code))
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
'Response Details:\r\n{}'.format(r.content))
|
||||||
|
|
||||||
|
# Mark our failure
|
||||||
|
has_error = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.logger.info(
|
||||||
|
'Sent Burst SMS notification to %d target(s).' %
|
||||||
|
len(self.targets[index:index + batch_size]))
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
self.logger.warning(
|
||||||
|
'A Connection error occurred sending Burst SMS '
|
||||||
|
'notification to %d target(s).' %
|
||||||
|
len(self.targets[index:index + batch_size]))
|
||||||
|
self.logger.debug('Socket Exception: %s' % str(e))
|
||||||
|
|
||||||
|
# Mark our failure
|
||||||
|
has_error = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
'country': self.country,
|
||||||
|
'batch': 'yes' if self.batch else 'no',
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.validity:
|
||||||
|
params['validity'] = str(self.validity)
|
||||||
|
|
||||||
|
# Extend our parameters
|
||||||
|
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
|
||||||
|
|
||||||
|
return '{schema}://{key}:{secret}@{source}/{targets}/?{params}'.format(
|
||||||
|
schema=self.secure_protocol,
|
||||||
|
key=self.pprint(self.apikey, privacy, safe=''),
|
||||||
|
secret=self.pprint(
|
||||||
|
self.secret, privacy, mode=PrivacyMode.Secret, safe=''),
|
||||||
|
source=NotifyBurstSMS.quote(self.source, safe=''),
|
||||||
|
targets='/'.join(
|
||||||
|
[NotifyBurstSMS.quote(x, safe='') for x in self.targets]),
|
||||||
|
params=NotifyBurstSMS.urlencode(params))
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
"""
|
||||||
|
Returns the number of targets associated with this notification
|
||||||
|
"""
|
||||||
|
#
|
||||||
|
# Factor batch into calculation
|
||||||
|
#
|
||||||
|
batch_size = 1 if not self.batch else self.default_batch_size
|
||||||
|
targets = len(self.targets)
|
||||||
|
if batch_size > 1:
|
||||||
|
targets = int(targets / batch_size) + \
|
||||||
|
(1 if targets % batch_size else 0)
|
||||||
|
|
||||||
|
return targets if targets > 0 else 1
|
||||||
|
|
||||||
|
@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 hostname is our source (Sender ID)
|
||||||
|
results['source'] = NotifyBurstSMS.unquote(results['host'])
|
||||||
|
|
||||||
|
# Get any remaining targets
|
||||||
|
results['targets'] = NotifyBurstSMS.split_path(results['fullpath'])
|
||||||
|
|
||||||
|
# Get our account_side and auth_token from the user/pass config
|
||||||
|
results['apikey'] = NotifyBurstSMS.unquote(results['user'])
|
||||||
|
results['secret'] = NotifyBurstSMS.unquote(results['password'])
|
||||||
|
|
||||||
|
# API Key
|
||||||
|
if 'key' in results['qsd'] and len(results['qsd']['key']):
|
||||||
|
# Extract the API Key from an argument
|
||||||
|
results['apikey'] = \
|
||||||
|
NotifyBurstSMS.unquote(results['qsd']['key'])
|
||||||
|
|
||||||
|
# API Secret
|
||||||
|
if 'secret' in results['qsd'] and len(results['qsd']['secret']):
|
||||||
|
# Extract the API Secret from an argument
|
||||||
|
results['secret'] = \
|
||||||
|
NotifyBurstSMS.unquote(results['qsd']['secret'])
|
||||||
|
|
||||||
|
# Support the 'from' and 'source' variable so that we can support
|
||||||
|
# targets this way too.
|
||||||
|
# The 'from' makes it easier to use yaml configuration
|
||||||
|
if 'from' in results['qsd'] and len(results['qsd']['from']):
|
||||||
|
results['source'] = \
|
||||||
|
NotifyBurstSMS.unquote(results['qsd']['from'])
|
||||||
|
if 'source' in results['qsd'] and len(results['qsd']['source']):
|
||||||
|
results['source'] = \
|
||||||
|
NotifyBurstSMS.unquote(results['qsd']['source'])
|
||||||
|
|
||||||
|
# Support country
|
||||||
|
if 'country' in results['qsd'] and len(results['qsd']['country']):
|
||||||
|
results['country'] = \
|
||||||
|
NotifyBurstSMS.unquote(results['qsd']['country'])
|
||||||
|
|
||||||
|
# Support validity value
|
||||||
|
if 'validity' in results['qsd'] and len(results['qsd']['validity']):
|
||||||
|
results['validity'] = \
|
||||||
|
NotifyBurstSMS.unquote(results['qsd']['validity'])
|
||||||
|
|
||||||
|
# Get Batch Mode Flag
|
||||||
|
if 'batch' in results['qsd'] and len(results['qsd']['batch']):
|
||||||
|
results['batch'] = parse_bool(results['qsd']['batch'])
|
||||||
|
|
||||||
|
# Support the 'to' variable so that we can support rooms this way too
|
||||||
|
# The 'to' makes it easier to use yaml configuration
|
||||||
|
if 'to' in results['qsd'] and len(results['qsd']['to']):
|
||||||
|
results['targets'] += \
|
||||||
|
NotifyBurstSMS.parse_phone_no(results['qsd']['to'])
|
||||||
|
|
||||||
|
return results
|
@ -1,425 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
# BSD 3-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.
|
|
||||||
#
|
|
||||||
# 3. Neither the name of the copyright holder nor the names of its
|
|
||||||
# contributors may be used to endorse or promote products derived from
|
|
||||||
# this software without specific prior written permission.
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
# Once you visit: https://developer.gitter.im/apps you'll get a personal
|
|
||||||
# access token that will look something like this:
|
|
||||||
# b5647881d563fm846dfbb2c27d1fe8f669b8f026
|
|
||||||
|
|
||||||
# Don't worry about generating an app; this token is all you need to form
|
|
||||||
# you're URL with. The syntax is as follows:
|
|
||||||
# gitter://{token}/{channel}
|
|
||||||
|
|
||||||
# Hence a URL might look like the following:
|
|
||||||
# gitter://b5647881d563fm846dfbb2c27d1fe8f669b8f026/apprise
|
|
||||||
|
|
||||||
# Note: You must have joined the channel to send a message to it!
|
|
||||||
|
|
||||||
# Official API reference: https://developer.gitter.im/docs/user-resource
|
|
||||||
|
|
||||||
import re
|
|
||||||
import requests
|
|
||||||
from json import loads
|
|
||||||
from json import dumps
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from .NotifyBase import NotifyBase
|
|
||||||
from ..common import NotifyImageSize
|
|
||||||
from ..common import NotifyFormat
|
|
||||||
from ..common import NotifyType
|
|
||||||
from ..utils import parse_list
|
|
||||||
from ..utils import parse_bool
|
|
||||||
from ..utils import validate_regex
|
|
||||||
from ..AppriseLocale import gettext_lazy as _
|
|
||||||
|
|
||||||
# API Gitter URL
|
|
||||||
GITTER_API_URL = 'https://api.gitter.im/v1'
|
|
||||||
|
|
||||||
# Used to break path apart into list of targets
|
|
||||||
TARGET_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+')
|
|
||||||
|
|
||||||
|
|
||||||
class NotifyGitter(NotifyBase):
|
|
||||||
"""
|
|
||||||
A wrapper for Gitter Notifications
|
|
||||||
"""
|
|
||||||
|
|
||||||
# The default descriptive name associated with the Notification
|
|
||||||
service_name = 'Gitter'
|
|
||||||
|
|
||||||
# The services URL
|
|
||||||
service_url = 'https://gitter.im/'
|
|
||||||
|
|
||||||
# All notification requests are secure
|
|
||||||
secure_protocol = 'gitter'
|
|
||||||
|
|
||||||
# A URL that takes you to the setup/help of the specific protocol
|
|
||||||
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_gitter'
|
|
||||||
|
|
||||||
# Allows the user to specify the NotifyImageSize object
|
|
||||||
image_size = NotifyImageSize.XY_32
|
|
||||||
|
|
||||||
# Gitter does not support a title
|
|
||||||
title_maxlen = 0
|
|
||||||
|
|
||||||
# Gitter is kind enough to return how many more requests we're allowed to
|
|
||||||
# continue to make within it's header response as:
|
|
||||||
# X-RateLimit-Reset: The epoc time (in seconds) we can expect our
|
|
||||||
# rate-limit to be reset.
|
|
||||||
# X-RateLimit-Remaining: an integer identifying how many requests we're
|
|
||||||
# still allow to make.
|
|
||||||
request_rate_per_sec = 0
|
|
||||||
|
|
||||||
# For Tracking Purposes
|
|
||||||
ratelimit_reset = datetime.utcnow()
|
|
||||||
|
|
||||||
# Default to 1
|
|
||||||
ratelimit_remaining = 1
|
|
||||||
|
|
||||||
# Default Notification Format
|
|
||||||
notify_format = NotifyFormat.MARKDOWN
|
|
||||||
|
|
||||||
# Define object templates
|
|
||||||
templates = (
|
|
||||||
'{schema}://{token}/{targets}/',
|
|
||||||
)
|
|
||||||
|
|
||||||
# Define our template tokens
|
|
||||||
template_tokens = dict(NotifyBase.template_tokens, **{
|
|
||||||
'token': {
|
|
||||||
'name': _('Token'),
|
|
||||||
'type': 'string',
|
|
||||||
'private': True,
|
|
||||||
'required': True,
|
|
||||||
'regex': (r'^[a-z0-9]{40}$', 'i'),
|
|
||||||
},
|
|
||||||
'targets': {
|
|
||||||
'name': _('Rooms'),
|
|
||||||
'type': 'list:string',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
# Define our template arguments
|
|
||||||
template_args = dict(NotifyBase.template_args, **{
|
|
||||||
'image': {
|
|
||||||
'name': _('Include Image'),
|
|
||||||
'type': 'bool',
|
|
||||||
'default': False,
|
|
||||||
'map_to': 'include_image',
|
|
||||||
},
|
|
||||||
'to': {
|
|
||||||
'alias_of': 'targets',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
def __init__(self, token, targets, include_image=False, **kwargs):
|
|
||||||
"""
|
|
||||||
Initialize Gitter Object
|
|
||||||
"""
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
|
|
||||||
# Secret Key (associated with project)
|
|
||||||
self.token = validate_regex(
|
|
||||||
token, *self.template_tokens['token']['regex'])
|
|
||||||
if not self.token:
|
|
||||||
msg = 'An invalid Gitter API Token ' \
|
|
||||||
'({}) was specified.'.format(token)
|
|
||||||
self.logger.warning(msg)
|
|
||||||
raise TypeError(msg)
|
|
||||||
|
|
||||||
# Parse our targets
|
|
||||||
self.targets = parse_list(targets)
|
|
||||||
if not self.targets:
|
|
||||||
msg = 'There are no valid Gitter targets to notify.'
|
|
||||||
self.logger.warning(msg)
|
|
||||||
raise TypeError(msg)
|
|
||||||
|
|
||||||
# Used to track maping of rooms to their numeric id lookup for
|
|
||||||
# messaging
|
|
||||||
self._room_mapping = None
|
|
||||||
|
|
||||||
# Track whether or not we want to send an image with our notification
|
|
||||||
# or not.
|
|
||||||
self.include_image = include_image
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
|
||||||
"""
|
|
||||||
Perform Gitter Notification
|
|
||||||
"""
|
|
||||||
|
|
||||||
# error tracking (used for function return)
|
|
||||||
has_error = False
|
|
||||||
|
|
||||||
# Set up our image for display if configured to do so
|
|
||||||
image_url = None if not self.include_image \
|
|
||||||
else self.image_url(notify_type)
|
|
||||||
|
|
||||||
if image_url:
|
|
||||||
body = '![alt]({})\n{}'.format(image_url, body)
|
|
||||||
|
|
||||||
if self._room_mapping is None:
|
|
||||||
# Populate our room mapping
|
|
||||||
self._room_mapping = {}
|
|
||||||
postokay, response = self._fetch(url='rooms')
|
|
||||||
if not postokay:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Response generally looks like this:
|
|
||||||
# [
|
|
||||||
# {
|
|
||||||
# noindex: False,
|
|
||||||
# oneToOne: False,
|
|
||||||
# avatarUrl: 'https://path/to/avatar/url',
|
|
||||||
# url: '/apprise-notifications/community',
|
|
||||||
# public: True,
|
|
||||||
# tags: [],
|
|
||||||
# lurk: False,
|
|
||||||
# uri: 'apprise-notifications/community',
|
|
||||||
# lastAccessTime: '2019-03-25T00:12:28.144Z',
|
|
||||||
# topic: '',
|
|
||||||
# roomMember: True,
|
|
||||||
# groupId: '5c981cecd73408ce4fbbad2f',
|
|
||||||
# githubType: 'REPO_CHANNEL',
|
|
||||||
# unreadItems: 0,
|
|
||||||
# mentions: 0,
|
|
||||||
# security: 'PUBLIC',
|
|
||||||
# userCount: 1,
|
|
||||||
# id: '5c981cecd73408ce4fbbad31',
|
|
||||||
# name: 'apprise/community'
|
|
||||||
# }
|
|
||||||
# ]
|
|
||||||
for entry in response:
|
|
||||||
self._room_mapping[entry['name'].lower().split('/')[0]] = {
|
|
||||||
# The ID of the room
|
|
||||||
'id': entry['id'],
|
|
||||||
|
|
||||||
# A descriptive name (useful for logging)
|
|
||||||
'uri': entry['uri'],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create a copy of the targets list
|
|
||||||
targets = list(self.targets)
|
|
||||||
while len(targets):
|
|
||||||
target = targets.pop(0).lower()
|
|
||||||
|
|
||||||
if target not in self._room_mapping:
|
|
||||||
self.logger.warning(
|
|
||||||
'Failed to locate Gitter room {}'.format(target))
|
|
||||||
|
|
||||||
# Flag our error
|
|
||||||
has_error = True
|
|
||||||
continue
|
|
||||||
|
|
||||||
# prepare our payload
|
|
||||||
payload = {
|
|
||||||
'text': body,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Our Notification URL
|
|
||||||
notify_url = 'rooms/{}/chatMessages'.format(
|
|
||||||
self._room_mapping[target]['id'])
|
|
||||||
|
|
||||||
# Perform our query
|
|
||||||
postokay, response = self._fetch(
|
|
||||||
notify_url, payload=dumps(payload), method='POST')
|
|
||||||
|
|
||||||
if not postokay:
|
|
||||||
# Flag our error
|
|
||||||
has_error = True
|
|
||||||
|
|
||||||
return not has_error
|
|
||||||
|
|
||||||
def _fetch(self, url, payload=None, method='GET'):
|
|
||||||
"""
|
|
||||||
Wrapper to request object
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Prepare our headers:
|
|
||||||
headers = {
|
|
||||||
'User-Agent': self.app_id,
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Authorization': 'Bearer ' + self.token,
|
|
||||||
}
|
|
||||||
if payload:
|
|
||||||
# Only set our header payload if it's defined
|
|
||||||
headers['Content-Type'] = 'application/json'
|
|
||||||
|
|
||||||
# Default content response object
|
|
||||||
content = {}
|
|
||||||
|
|
||||||
# Update our URL
|
|
||||||
url = '{}/{}'.format(GITTER_API_URL, url)
|
|
||||||
|
|
||||||
# Some Debug Logging
|
|
||||||
self.logger.debug('Gitter {} URL: {} (cert_verify={})'.format(
|
|
||||||
method,
|
|
||||||
url, self.verify_certificate))
|
|
||||||
if payload:
|
|
||||||
self.logger.debug('Gitter Payload: {}' .format(payload))
|
|
||||||
|
|
||||||
# By default set wait to None
|
|
||||||
wait = None
|
|
||||||
|
|
||||||
if self.ratelimit_remaining <= 0:
|
|
||||||
# Determine how long we should wait for or if we should wait at
|
|
||||||
# all. This isn't fool-proof because we can't be sure the client
|
|
||||||
# time (calling this script) is completely synced up with the
|
|
||||||
# Gitter server. One would hope we're on NTP and our clocks are
|
|
||||||
# the same allowing this to role smoothly:
|
|
||||||
|
|
||||||
now = datetime.utcnow()
|
|
||||||
if now < self.ratelimit_reset:
|
|
||||||
# We need to throttle for the difference in seconds
|
|
||||||
# We add 0.5 seconds to the end just to allow a grace
|
|
||||||
# period.
|
|
||||||
wait = (self.ratelimit_reset - now).total_seconds() + 0.5
|
|
||||||
|
|
||||||
# Always call throttle before any remote server i/o is made
|
|
||||||
self.throttle(wait=wait)
|
|
||||||
|
|
||||||
# fetch function
|
|
||||||
fn = requests.post if method == 'POST' else requests.get
|
|
||||||
try:
|
|
||||||
r = fn(
|
|
||||||
url,
|
|
||||||
data=payload,
|
|
||||||
headers=headers,
|
|
||||||
verify=self.verify_certificate,
|
|
||||||
timeout=self.request_timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
if r.status_code != requests.codes.ok:
|
|
||||||
# We had a problem
|
|
||||||
status_str = \
|
|
||||||
NotifyGitter.http_response_code_lookup(r.status_code)
|
|
||||||
|
|
||||||
self.logger.warning(
|
|
||||||
'Failed to send Gitter {} to {}: '
|
|
||||||
'{}error={}.'.format(
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
', ' if status_str else '',
|
|
||||||
r.status_code))
|
|
||||||
|
|
||||||
self.logger.debug(
|
|
||||||
'Response Details:\r\n{}'.format(r.content))
|
|
||||||
|
|
||||||
# Mark our failure
|
|
||||||
return (False, content)
|
|
||||||
|
|
||||||
try:
|
|
||||||
content = loads(r.content)
|
|
||||||
|
|
||||||
except (AttributeError, TypeError, ValueError):
|
|
||||||
# ValueError = r.content is Unparsable
|
|
||||||
# TypeError = r.content is None
|
|
||||||
# AttributeError = r is None
|
|
||||||
content = {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.ratelimit_remaining = \
|
|
||||||
int(r.headers.get('X-RateLimit-Remaining'))
|
|
||||||
self.ratelimit_reset = datetime.utcfromtimestamp(
|
|
||||||
int(r.headers.get('X-RateLimit-Reset')))
|
|
||||||
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
# This is returned if we could not retrieve this information
|
|
||||||
# gracefully accept this state and move on
|
|
||||||
pass
|
|
||||||
|
|
||||||
except requests.RequestException as e:
|
|
||||||
self.logger.warning(
|
|
||||||
'Exception received when sending Gitter {} to {}: '.
|
|
||||||
format(method, url))
|
|
||||||
self.logger.debug('Socket Exception: %s' % str(e))
|
|
||||||
|
|
||||||
# Mark our failure
|
|
||||||
return (False, content)
|
|
||||||
|
|
||||||
return (True, content)
|
|
||||||
|
|
||||||
def url(self, privacy=False, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Returns the URL built dynamically based on specified arguments.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Define any URL parameters
|
|
||||||
params = {
|
|
||||||
'image': 'yes' if self.include_image else 'no',
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extend our parameters
|
|
||||||
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
|
|
||||||
|
|
||||||
return '{schema}://{token}/{targets}/?{params}'.format(
|
|
||||||
schema=self.secure_protocol,
|
|
||||||
token=self.pprint(self.token, privacy, safe=''),
|
|
||||||
targets='/'.join(
|
|
||||||
[NotifyGitter.quote(x, safe='') for x in self.targets]),
|
|
||||||
params=NotifyGitter.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
|
|
||||||
|
|
||||||
results['token'] = NotifyGitter.unquote(results['host'])
|
|
||||||
|
|
||||||
# Get our entries; split_path() looks after unquoting content for us
|
|
||||||
# by default
|
|
||||||
results['targets'] = NotifyGitter.split_path(results['fullpath'])
|
|
||||||
|
|
||||||
# Support the 'to' variable so that we can support targets this way too
|
|
||||||
# The 'to' makes it easier to use yaml configuration
|
|
||||||
if 'to' in results['qsd'] and len(results['qsd']['to']):
|
|
||||||
results['targets'] += NotifyGitter.parse_list(results['qsd']['to'])
|
|
||||||
|
|
||||||
# Include images with our message
|
|
||||||
results['include_image'] = \
|
|
||||||
parse_bool(results['qsd'].get('image', False))
|
|
||||||
|
|
||||||
return results
|
|
@ -0,0 +1,372 @@
|
|||||||
|
# -*- 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.
|
||||||
|
|
||||||
|
# Create an incoming webhook; the website will provide you with something like:
|
||||||
|
# http://localhost:8065/hooks/yobjmukpaw3r3urc5h6i369yima
|
||||||
|
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
# |-- this is the webhook --|
|
||||||
|
#
|
||||||
|
# You can effectively turn the url above to read this:
|
||||||
|
# mmost://localhost:8065/yobjmukpaw3r3urc5h6i369yima
|
||||||
|
# - swap http with mmost
|
||||||
|
# - drop /hooks/ reference
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from json import dumps
|
||||||
|
|
||||||
|
from .NotifyBase import NotifyBase
|
||||||
|
from ..common import NotifyImageSize
|
||||||
|
from ..common import NotifyType
|
||||||
|
from ..utils import parse_bool
|
||||||
|
from ..utils import parse_list
|
||||||
|
from ..utils import validate_regex
|
||||||
|
from ..AppriseLocale import gettext_lazy as _
|
||||||
|
|
||||||
|
# Some Reference Locations:
|
||||||
|
# - https://docs.mattermost.com/developer/webhooks-incoming.html
|
||||||
|
# - https://docs.mattermost.com/administration/config-settings.html
|
||||||
|
|
||||||
|
|
||||||
|
class NotifyMattermost(NotifyBase):
|
||||||
|
"""
|
||||||
|
A wrapper for Mattermost Notifications
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The default descriptive name associated with the Notification
|
||||||
|
service_name = 'Mattermost'
|
||||||
|
|
||||||
|
# The services URL
|
||||||
|
service_url = 'https://mattermost.com/'
|
||||||
|
|
||||||
|
# The default protocol
|
||||||
|
protocol = 'mmost'
|
||||||
|
|
||||||
|
# The default secure protocol
|
||||||
|
secure_protocol = 'mmosts'
|
||||||
|
|
||||||
|
# A URL that takes you to the setup/help of the specific protocol
|
||||||
|
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_mattermost'
|
||||||
|
|
||||||
|
# The default Mattermost port
|
||||||
|
default_port = 8065
|
||||||
|
|
||||||
|
# Allows the user to specify the NotifyImageSize object
|
||||||
|
image_size = NotifyImageSize.XY_72
|
||||||
|
|
||||||
|
# The maximum allowable characters allowed in the body per message
|
||||||
|
body_maxlen = 4000
|
||||||
|
|
||||||
|
# Mattermost does not have a title
|
||||||
|
title_maxlen = 0
|
||||||
|
|
||||||
|
# Define object templates
|
||||||
|
templates = (
|
||||||
|
'{schema}://{host}/{token}',
|
||||||
|
'{schema}://{host}:{port}/{token}',
|
||||||
|
'{schema}://{host}/{fullpath}/{token}',
|
||||||
|
'{schema}://{host}:{port}/{fullpath}/{token}',
|
||||||
|
'{schema}://{botname}@{host}/{token}',
|
||||||
|
'{schema}://{botname}@{host}:{port}/{token}',
|
||||||
|
'{schema}://{botname}@{host}/{fullpath}/{token}',
|
||||||
|
'{schema}://{botname}@{host}:{port}/{fullpath}/{token}',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define our template tokens
|
||||||
|
template_tokens = dict(NotifyBase.template_tokens, **{
|
||||||
|
'host': {
|
||||||
|
'name': _('Hostname'),
|
||||||
|
'type': 'string',
|
||||||
|
'required': True,
|
||||||
|
},
|
||||||
|
'token': {
|
||||||
|
'name': _('Webhook Token'),
|
||||||
|
'type': 'string',
|
||||||
|
'private': True,
|
||||||
|
'required': True,
|
||||||
|
},
|
||||||
|
'fullpath': {
|
||||||
|
'name': _('Path'),
|
||||||
|
'type': 'string',
|
||||||
|
},
|
||||||
|
'botname': {
|
||||||
|
'name': _('Bot Name'),
|
||||||
|
'type': 'string',
|
||||||
|
'map_to': 'user',
|
||||||
|
},
|
||||||
|
'port': {
|
||||||
|
'name': _('Port'),
|
||||||
|
'type': 'int',
|
||||||
|
'min': 1,
|
||||||
|
'max': 65535,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
# Define our template arguments
|
||||||
|
template_args = dict(NotifyBase.template_args, **{
|
||||||
|
'channels': {
|
||||||
|
'name': _('Channels'),
|
||||||
|
'type': 'list:string',
|
||||||
|
},
|
||||||
|
'image': {
|
||||||
|
'name': _('Include Image'),
|
||||||
|
'type': 'bool',
|
||||||
|
'default': True,
|
||||||
|
'map_to': 'include_image',
|
||||||
|
},
|
||||||
|
'to': {
|
||||||
|
'alias_of': 'channels',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
def __init__(self, token, fullpath=None, channels=None,
|
||||||
|
include_image=False, **kwargs):
|
||||||
|
"""
|
||||||
|
Initialize Mattermost Object
|
||||||
|
"""
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
if self.secure:
|
||||||
|
self.schema = 'https'
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.schema = 'http'
|
||||||
|
|
||||||
|
# our full path
|
||||||
|
self.fullpath = '' if not isinstance(
|
||||||
|
fullpath, str) else fullpath.strip()
|
||||||
|
|
||||||
|
# Authorization Token (associated with project)
|
||||||
|
self.token = validate_regex(token)
|
||||||
|
if not self.token:
|
||||||
|
msg = 'An invalid Mattermost Authorization Token ' \
|
||||||
|
'({}) was specified.'.format(token)
|
||||||
|
self.logger.warning(msg)
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
# Optional Channels (strip off any channel prefix entries if present)
|
||||||
|
self.channels = [x.lstrip('#') for x in parse_list(channels)]
|
||||||
|
|
||||||
|
if not self.port:
|
||||||
|
self.port = self.default_port
|
||||||
|
|
||||||
|
# Place a thumbnail image inline with the message body
|
||||||
|
self.include_image = include_image
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||||
|
"""
|
||||||
|
Perform Mattermost Notification
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Create a copy of our channels, otherwise place a dummy entry
|
||||||
|
channels = list(self.channels) if self.channels else [None, ]
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': self.app_id,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
# prepare JSON Object
|
||||||
|
payload = {
|
||||||
|
'text': body,
|
||||||
|
'icon_url': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Acquire our image url if configured to do so
|
||||||
|
image_url = None if not self.include_image \
|
||||||
|
else self.image_url(notify_type)
|
||||||
|
|
||||||
|
if image_url:
|
||||||
|
# Set our image configuration if told to do so
|
||||||
|
payload['icon_url'] = image_url
|
||||||
|
|
||||||
|
# Set our user
|
||||||
|
payload['username'] = self.user if self.user else self.app_id
|
||||||
|
|
||||||
|
# For error tracking
|
||||||
|
has_error = False
|
||||||
|
|
||||||
|
while len(channels):
|
||||||
|
# Pop a channel off of the list
|
||||||
|
channel = channels.pop(0)
|
||||||
|
|
||||||
|
if channel:
|
||||||
|
payload['channel'] = channel
|
||||||
|
|
||||||
|
url = '{}://{}:{}{}/hooks/{}'.format(
|
||||||
|
self.schema, self.host, self.port, self.fullpath,
|
||||||
|
self.token)
|
||||||
|
|
||||||
|
self.logger.debug('Mattermost POST URL: %s (cert_verify=%r)' % (
|
||||||
|
url, self.verify_certificate,
|
||||||
|
))
|
||||||
|
self.logger.debug('Mattermost Payload: %s' % str(payload))
|
||||||
|
|
||||||
|
# Always call throttle before any remote server i/o is made
|
||||||
|
self.throttle()
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
url,
|
||||||
|
data=dumps(payload),
|
||||||
|
headers=headers,
|
||||||
|
verify=self.verify_certificate,
|
||||||
|
timeout=self.request_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
if r.status_code != requests.codes.ok:
|
||||||
|
# We had a problem
|
||||||
|
status_str = \
|
||||||
|
NotifyMattermost.http_response_code_lookup(
|
||||||
|
r.status_code)
|
||||||
|
|
||||||
|
self.logger.warning(
|
||||||
|
'Failed to send Mattermost notification{}: '
|
||||||
|
'{}{}error={}.'.format(
|
||||||
|
'' if not channel
|
||||||
|
else ' to channel {}'.format(channel),
|
||||||
|
status_str,
|
||||||
|
', ' if status_str else '',
|
||||||
|
r.status_code))
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
'Response Details:\r\n{}'.format(r.content))
|
||||||
|
|
||||||
|
# Flag our error
|
||||||
|
has_error = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.logger.info(
|
||||||
|
'Sent Mattermost notification{}.'.format(
|
||||||
|
'' if not channel
|
||||||
|
else ' to channel {}'.format(channel)))
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
self.logger.warning(
|
||||||
|
'A Connection error occurred sending Mattermost '
|
||||||
|
'notification{}.'.format(
|
||||||
|
'' if not channel
|
||||||
|
else ' to channel {}'.format(channel)))
|
||||||
|
self.logger.debug('Socket Exception: %s' % str(e))
|
||||||
|
|
||||||
|
# Flag our error
|
||||||
|
has_error = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Return our overall status
|
||||||
|
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 = {
|
||||||
|
'image': 'yes' if self.include_image else 'no',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extend our parameters
|
||||||
|
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
|
||||||
|
|
||||||
|
if self.channels:
|
||||||
|
# historically the value only accepted one channel and is
|
||||||
|
# therefore identified as 'channel'. Channels have always been
|
||||||
|
# optional, so that is why this setting is nested in an if block
|
||||||
|
params['channel'] = ','.join(
|
||||||
|
[NotifyMattermost.quote(x, safe='') for x in self.channels])
|
||||||
|
|
||||||
|
default_port = 443 if self.secure else self.default_port
|
||||||
|
default_schema = self.secure_protocol if self.secure else self.protocol
|
||||||
|
|
||||||
|
# Determine if there is a botname present
|
||||||
|
botname = ''
|
||||||
|
if self.user:
|
||||||
|
botname = '{botname}@'.format(
|
||||||
|
botname=NotifyMattermost.quote(self.user, safe=''),
|
||||||
|
)
|
||||||
|
|
||||||
|
return \
|
||||||
|
'{schema}://{botname}{hostname}{port}{fullpath}{token}' \
|
||||||
|
'/?{params}'.format(
|
||||||
|
schema=default_schema,
|
||||||
|
botname=botname,
|
||||||
|
# never encode hostname since we're expecting it to be a valid
|
||||||
|
# one
|
||||||
|
hostname=self.host,
|
||||||
|
port='' if not self.port or self.port == default_port
|
||||||
|
else ':{}'.format(self.port),
|
||||||
|
fullpath='/' if not self.fullpath else '{}/'.format(
|
||||||
|
NotifyMattermost.quote(self.fullpath, safe='/')),
|
||||||
|
token=self.pprint(self.token, privacy, safe=''),
|
||||||
|
params=NotifyMattermost.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)
|
||||||
|
if not results:
|
||||||
|
# We're done early as we couldn't load the results
|
||||||
|
return results
|
||||||
|
|
||||||
|
# Acquire our tokens; the last one will always be our token
|
||||||
|
# all entries before it will be our path
|
||||||
|
tokens = NotifyMattermost.split_path(results['fullpath'])
|
||||||
|
|
||||||
|
results['token'] = None if not tokens else tokens.pop()
|
||||||
|
|
||||||
|
# Store our path
|
||||||
|
results['fullpath'] = '' if not tokens \
|
||||||
|
else '/{}'.format('/'.join(tokens))
|
||||||
|
|
||||||
|
# Define our optional list of channels to notify
|
||||||
|
results['channels'] = list()
|
||||||
|
|
||||||
|
# Support both 'to' (for yaml configuration) and channel=
|
||||||
|
if 'to' in results['qsd'] and len(results['qsd']['to']):
|
||||||
|
# Allow the user to specify the channel to post to
|
||||||
|
results['channels'].append(
|
||||||
|
NotifyMattermost.parse_list(results['qsd']['to']))
|
||||||
|
|
||||||
|
if 'channel' in results['qsd'] and len(results['qsd']['channel']):
|
||||||
|
# Allow the user to specify the channel to post to
|
||||||
|
results['channels'].append(
|
||||||
|
NotifyMattermost.parse_list(results['qsd']['channel']))
|
||||||
|
|
||||||
|
# Image manipulation
|
||||||
|
results['include_image'] = \
|
||||||
|
parse_bool(results['qsd'].get('image', False))
|
||||||
|
|
||||||
|
return results
|
@ -0,0 +1,472 @@
|
|||||||
|
# -*- 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 json import dumps
|
||||||
|
from itertools import chain
|
||||||
|
|
||||||
|
from .NotifyBase import NotifyBase
|
||||||
|
from ..common import NotifyType
|
||||||
|
from ..AppriseLocale import gettext_lazy as _
|
||||||
|
from ..common import NotifyImageSize
|
||||||
|
from ..utils import parse_list, parse_bool
|
||||||
|
from ..utils import validate_regex
|
||||||
|
|
||||||
|
# Used to break path apart into list of channels
|
||||||
|
CHANNEL_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+')
|
||||||
|
|
||||||
|
CHANNEL_REGEX = re.compile(
|
||||||
|
r'^\s*(\#|\%35)?(?P<channel>[0-9]+)', re.I)
|
||||||
|
|
||||||
|
# For API Details see:
|
||||||
|
# https://notifiarr.wiki/Client/Installation
|
||||||
|
|
||||||
|
# Another good example:
|
||||||
|
# https://notifiarr.wiki/en/Website/ \
|
||||||
|
# Integrations/Passthrough#payload-example-1
|
||||||
|
|
||||||
|
|
||||||
|
class NotifyNotifiarr(NotifyBase):
|
||||||
|
"""
|
||||||
|
A wrapper for Notifiarr Notifications
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The default descriptive name associated with the Notification
|
||||||
|
service_name = 'Notifiarr'
|
||||||
|
|
||||||
|
# The services URL
|
||||||
|
service_url = 'https://notifiarr.com/'
|
||||||
|
|
||||||
|
# The default secure protocol
|
||||||
|
secure_protocol = 'notifiarr'
|
||||||
|
|
||||||
|
# A URL that takes you to the setup/help of the specific protocol
|
||||||
|
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_notifiarr'
|
||||||
|
|
||||||
|
# The Notification URL
|
||||||
|
notify_url = 'https://notifiarr.com/api/v1/notification/apprise'
|
||||||
|
|
||||||
|
# Notifiarr Throttling (knowing in advance reduces 429 responses)
|
||||||
|
# define('NOTIFICATION_LIMIT_SECOND_USER', 5);
|
||||||
|
# define('NOTIFICATION_LIMIT_SECOND_PATRON', 15);
|
||||||
|
|
||||||
|
# Throttle requests ever so slightly
|
||||||
|
request_rate_per_sec = 0.04
|
||||||
|
|
||||||
|
# Allows the user to specify the NotifyImageSize object
|
||||||
|
image_size = NotifyImageSize.XY_256
|
||||||
|
|
||||||
|
# Define object templates
|
||||||
|
templates = (
|
||||||
|
'{schema}://{apikey}/{targets}',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define our apikeys; these are the minimum apikeys required required to
|
||||||
|
# be passed into this function (as arguments). The syntax appends any
|
||||||
|
# previously defined in the base package and builds onto them
|
||||||
|
template_tokens = dict(NotifyBase.template_tokens, **{
|
||||||
|
'apikey': {
|
||||||
|
'name': _('Token'),
|
||||||
|
'type': 'string',
|
||||||
|
'required': True,
|
||||||
|
'private': True,
|
||||||
|
},
|
||||||
|
'target_channel': {
|
||||||
|
'name': _('Target Channel'),
|
||||||
|
'type': 'string',
|
||||||
|
'prefix': '#',
|
||||||
|
'map_to': 'targets',
|
||||||
|
},
|
||||||
|
'targets': {
|
||||||
|
'name': _('Targets'),
|
||||||
|
'type': 'list:string',
|
||||||
|
'required': True,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
# Define our template arguments
|
||||||
|
template_args = dict(NotifyBase.template_args, **{
|
||||||
|
'key': {
|
||||||
|
'alias_of': 'apikey',
|
||||||
|
},
|
||||||
|
'apikey': {
|
||||||
|
'alias_of': 'apikey',
|
||||||
|
},
|
||||||
|
'discord_user': {
|
||||||
|
'name': _('Ping Discord User'),
|
||||||
|
'type': 'int',
|
||||||
|
},
|
||||||
|
'discord_role': {
|
||||||
|
'name': _('Ping Discord Role'),
|
||||||
|
'type': 'int',
|
||||||
|
},
|
||||||
|
'event': {
|
||||||
|
'name': _('Discord Event ID'),
|
||||||
|
'type': 'int',
|
||||||
|
},
|
||||||
|
'image': {
|
||||||
|
'name': _('Include Image'),
|
||||||
|
'type': 'bool',
|
||||||
|
'default': False,
|
||||||
|
'map_to': 'include_image',
|
||||||
|
},
|
||||||
|
'source': {
|
||||||
|
'name': _('Source'),
|
||||||
|
'type': 'string',
|
||||||
|
},
|
||||||
|
'from': {
|
||||||
|
'alias_of': 'source'
|
||||||
|
},
|
||||||
|
'to': {
|
||||||
|
'alias_of': 'targets',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
def __init__(self, apikey=None, include_image=None,
|
||||||
|
discord_user=None, discord_role=None,
|
||||||
|
event=None, targets=None, source=None, **kwargs):
|
||||||
|
"""
|
||||||
|
Initialize Notifiarr Object
|
||||||
|
|
||||||
|
headers can be a dictionary of key/value pairs that you want to
|
||||||
|
additionally include as part of the server headers to post with
|
||||||
|
|
||||||
|
"""
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
self.apikey = apikey
|
||||||
|
if not self.apikey:
|
||||||
|
msg = 'An invalid Notifiarr APIKey ' \
|
||||||
|
'({}) was specified.'.format(apikey)
|
||||||
|
self.logger.warning(msg)
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
# Place a thumbnail image inline with the message body
|
||||||
|
self.include_image = include_image \
|
||||||
|
if isinstance(include_image, bool) \
|
||||||
|
else self.template_args['image']['default']
|
||||||
|
|
||||||
|
# Set up our user if specified
|
||||||
|
self.discord_user = 0
|
||||||
|
if discord_user:
|
||||||
|
try:
|
||||||
|
self.discord_user = int(discord_user)
|
||||||
|
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
msg = 'An invalid Notifiarr User ID ' \
|
||||||
|
'({}) was specified.'.format(discord_user)
|
||||||
|
self.logger.warning(msg)
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
# Set up our role if specified
|
||||||
|
self.discord_role = 0
|
||||||
|
if discord_role:
|
||||||
|
try:
|
||||||
|
self.discord_role = int(discord_role)
|
||||||
|
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
msg = 'An invalid Notifiarr Role ID ' \
|
||||||
|
'({}) was specified.'.format(discord_role)
|
||||||
|
self.logger.warning(msg)
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
# Prepare our source (if set)
|
||||||
|
self.source = validate_regex(source)
|
||||||
|
|
||||||
|
self.event = 0
|
||||||
|
if event:
|
||||||
|
try:
|
||||||
|
self.event = int(event)
|
||||||
|
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
msg = 'An invalid Notifiarr Discord Event ID ' \
|
||||||
|
'({}) was specified.'.format(event)
|
||||||
|
self.logger.warning(msg)
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
# Prepare our targets
|
||||||
|
self.targets = {
|
||||||
|
'channels': [],
|
||||||
|
'invalid': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for target in parse_list(targets):
|
||||||
|
result = CHANNEL_REGEX.match(target)
|
||||||
|
if result:
|
||||||
|
# Store role information
|
||||||
|
self.targets['channels'].append(int(result.group('channel')))
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.logger.warning(
|
||||||
|
'Dropped invalid channel '
|
||||||
|
'({}) specified.'.format(target),
|
||||||
|
)
|
||||||
|
self.targets['invalid'].append(target)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
def url(self, privacy=False, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Returns the URL built dynamically based on specified arguments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Define any URL parameters
|
||||||
|
params = {
|
||||||
|
'image': 'yes' if self.include_image else 'no',
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.source:
|
||||||
|
params['source'] = self.source
|
||||||
|
|
||||||
|
if self.discord_user:
|
||||||
|
params['discord_user'] = self.discord_user
|
||||||
|
|
||||||
|
if self.discord_role:
|
||||||
|
params['discord_role'] = self.discord_role
|
||||||
|
|
||||||
|
if self.event:
|
||||||
|
params['event'] = self.event
|
||||||
|
|
||||||
|
# Extend our parameters
|
||||||
|
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
|
||||||
|
|
||||||
|
return '{schema}://{apikey}' \
|
||||||
|
'/{targets}?{params}'.format(
|
||||||
|
schema=self.secure_protocol,
|
||||||
|
apikey=self.pprint(self.apikey, privacy, safe=''),
|
||||||
|
targets='/'.join(
|
||||||
|
[NotifyNotifiarr.quote(x, safe='+#@') for x in chain(
|
||||||
|
# Channels
|
||||||
|
['#{}'.format(x) for x in self.targets['channels']],
|
||||||
|
# Pass along the same invalid entries as were provided
|
||||||
|
self.targets['invalid'],
|
||||||
|
)]),
|
||||||
|
params=NotifyNotifiarr.urlencode(params),
|
||||||
|
)
|
||||||
|
|
||||||
|
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||||
|
"""
|
||||||
|
Perform Notifiarr Notification
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not self.targets['channels']:
|
||||||
|
# There were no services to notify
|
||||||
|
self.logger.warning(
|
||||||
|
'There were no Notifiarr channels to notify.')
|
||||||
|
return False
|
||||||
|
|
||||||
|
# No error to start with
|
||||||
|
has_error = False
|
||||||
|
|
||||||
|
# Acquire image_url
|
||||||
|
image_url = self.image_url(notify_type)
|
||||||
|
|
||||||
|
for idx, channel in enumerate(self.targets['channels']):
|
||||||
|
# prepare Notifiarr Object
|
||||||
|
payload = {
|
||||||
|
'source': self.source if self.source else self.app_id,
|
||||||
|
'type': notify_type,
|
||||||
|
'notification': {
|
||||||
|
'update': True if self.event else False,
|
||||||
|
'name': self.app_id,
|
||||||
|
'event': str(self.event)
|
||||||
|
if self.event else "",
|
||||||
|
},
|
||||||
|
'discord': {
|
||||||
|
'color': self.color(notify_type),
|
||||||
|
'ping': {
|
||||||
|
'pingUser': self.discord_user
|
||||||
|
if not idx and self.discord_user else 0,
|
||||||
|
'pingRole': self.discord_role
|
||||||
|
if not idx and self.discord_role else 0,
|
||||||
|
},
|
||||||
|
'text': {
|
||||||
|
'title': title,
|
||||||
|
'content': '',
|
||||||
|
'description': body,
|
||||||
|
'footer': self.app_desc,
|
||||||
|
},
|
||||||
|
'ids': {
|
||||||
|
'channel': channel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.include_image and image_url:
|
||||||
|
payload['discord']['text']['icon'] = image_url
|
||||||
|
payload['discord']['images'] = {
|
||||||
|
'thumbnail': image_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not self._send(payload):
|
||||||
|
has_error = True
|
||||||
|
|
||||||
|
return not has_error
|
||||||
|
|
||||||
|
def _send(self, payload):
|
||||||
|
"""
|
||||||
|
Send notification
|
||||||
|
"""
|
||||||
|
self.logger.debug('Notifiarr POST URL: %s (cert_verify=%r)' % (
|
||||||
|
self.notify_url, self.verify_certificate,
|
||||||
|
))
|
||||||
|
self.logger.debug('Notifiarr Payload: %s' % str(payload))
|
||||||
|
|
||||||
|
# Prepare HTTP Headers
|
||||||
|
headers = {
|
||||||
|
'User-Agent': self.app_id,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'text/plain',
|
||||||
|
'X-api-Key': self.apikey,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Always call throttle before any remote server i/o is made
|
||||||
|
self.throttle()
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
self.notify_url,
|
||||||
|
data=dumps(payload),
|
||||||
|
headers=headers,
|
||||||
|
verify=self.verify_certificate,
|
||||||
|
timeout=self.request_timeout,
|
||||||
|
)
|
||||||
|
if r.status_code < 200 or r.status_code >= 300:
|
||||||
|
# We had a problem
|
||||||
|
status_str = \
|
||||||
|
NotifyNotifiarr.http_response_code_lookup(r.status_code)
|
||||||
|
|
||||||
|
self.logger.warning(
|
||||||
|
'Failed to send Notifiarr %s notification: '
|
||||||
|
'%serror=%s.',
|
||||||
|
status_str,
|
||||||
|
', ' if status_str else '',
|
||||||
|
str(r.status_code))
|
||||||
|
|
||||||
|
self.logger.debug('Response Details:\r\n{}'.format(r.content))
|
||||||
|
|
||||||
|
# Return; we're done
|
||||||
|
return False
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.logger.info('Sent Notifiarr notification.')
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
self.logger.warning(
|
||||||
|
'A Connection error occurred sending Notifiarr '
|
||||||
|
'Chat notification to %s.' % self.host)
|
||||||
|
self.logger.debug('Socket Exception: %s' % str(e))
|
||||||
|
|
||||||
|
# Return; we're done
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
"""
|
||||||
|
Returns the number of targets associated with this notification
|
||||||
|
"""
|
||||||
|
targets = len(self.targets['channels']) + len(self.targets['invalid'])
|
||||||
|
return targets if targets > 0 else 1
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
# Get channels
|
||||||
|
results['targets'] = NotifyNotifiarr.split_path(results['fullpath'])
|
||||||
|
|
||||||
|
if 'discord_user' in results['qsd'] and \
|
||||||
|
len(results['qsd']['discord_user']):
|
||||||
|
results['discord_user'] = \
|
||||||
|
NotifyNotifiarr.unquote(
|
||||||
|
results['qsd']['discord_user'])
|
||||||
|
|
||||||
|
if 'discord_role' in results['qsd'] and \
|
||||||
|
len(results['qsd']['discord_role']):
|
||||||
|
results['discord_role'] = \
|
||||||
|
NotifyNotifiarr.unquote(results['qsd']['discord_role'])
|
||||||
|
|
||||||
|
if 'event' in results['qsd'] and \
|
||||||
|
len(results['qsd']['event']):
|
||||||
|
results['event'] = \
|
||||||
|
NotifyNotifiarr.unquote(results['qsd']['event'])
|
||||||
|
|
||||||
|
# Include images with our message
|
||||||
|
results['include_image'] = \
|
||||||
|
parse_bool(results['qsd'].get('image', False))
|
||||||
|
|
||||||
|
# Track if we need to extract the hostname as a target
|
||||||
|
host_is_potential_target = False
|
||||||
|
|
||||||
|
if 'source' in results['qsd'] and len(results['qsd']['source']):
|
||||||
|
results['source'] = \
|
||||||
|
NotifyNotifiarr.unquote(results['qsd']['source'])
|
||||||
|
|
||||||
|
elif 'from' in results['qsd'] and len(results['qsd']['from']):
|
||||||
|
results['source'] = \
|
||||||
|
NotifyNotifiarr.unquote(results['qsd']['from'])
|
||||||
|
|
||||||
|
# Set our apikey if found as an argument
|
||||||
|
if 'apikey' in results['qsd'] and len(results['qsd']['apikey']):
|
||||||
|
results['apikey'] = \
|
||||||
|
NotifyNotifiarr.unquote(results['qsd']['apikey'])
|
||||||
|
|
||||||
|
host_is_potential_target = True
|
||||||
|
|
||||||
|
elif 'key' in results['qsd'] and len(results['qsd']['key']):
|
||||||
|
results['apikey'] = \
|
||||||
|
NotifyNotifiarr.unquote(results['qsd']['key'])
|
||||||
|
|
||||||
|
host_is_potential_target = True
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Pop the first element (this is the api key)
|
||||||
|
results['apikey'] = \
|
||||||
|
NotifyNotifiarr.unquote(results['host'])
|
||||||
|
|
||||||
|
if host_is_potential_target is True and results['host']:
|
||||||
|
results['targets'].append(NotifyNotifiarr.unquote(results['host']))
|
||||||
|
|
||||||
|
# Support the 'to' variable so that we can support rooms this way too
|
||||||
|
# The 'to' makes it easier to use yaml configuration
|
||||||
|
if 'to' in results['qsd'] and len(results['qsd']['to']):
|
||||||
|
results['targets'] += [x for x in filter(
|
||||||
|
bool, CHANNEL_LIST_DELIM.split(
|
||||||
|
NotifyNotifiarr.unquote(results['qsd']['to'])))]
|
||||||
|
|
||||||
|
return results
|
@ -0,0 +1,218 @@
|
|||||||
|
# -*- 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 requests
|
||||||
|
|
||||||
|
from ..common import NotifyType
|
||||||
|
from .NotifyBase import NotifyBase
|
||||||
|
from ..utils import validate_regex
|
||||||
|
from ..AppriseLocale import gettext_lazy as _
|
||||||
|
|
||||||
|
# Syntax:
|
||||||
|
# schan://{key}/
|
||||||
|
|
||||||
|
|
||||||
|
class NotifyPushDeer(NotifyBase):
|
||||||
|
"""
|
||||||
|
A wrapper for PushDeer Notifications
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The default descriptive name associated with the Notification
|
||||||
|
service_name = 'PushDeer'
|
||||||
|
|
||||||
|
# The services URL
|
||||||
|
service_url = 'https://www.pushdeer.com/'
|
||||||
|
|
||||||
|
# Insecure Protocol Access
|
||||||
|
protocol = 'pushdeer'
|
||||||
|
|
||||||
|
# Secure Protocol
|
||||||
|
secure_protocol = 'pushdeers'
|
||||||
|
|
||||||
|
# A URL that takes you to the setup/help of the specific protocol
|
||||||
|
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_PushDeer'
|
||||||
|
|
||||||
|
# Default hostname
|
||||||
|
default_hostname = 'api2.pushdeer.com'
|
||||||
|
|
||||||
|
# PushDeer API
|
||||||
|
notify_url = '{schema}://{host}:{port}/message/push?pushkey={pushKey}'
|
||||||
|
|
||||||
|
# Define object templates
|
||||||
|
templates = (
|
||||||
|
'{schema}://{pushkey}',
|
||||||
|
'{schema}://{host}/{pushkey}',
|
||||||
|
'{schema}://{host}:{port}/{pushkey}',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define our template tokens
|
||||||
|
template_tokens = dict(NotifyBase.template_tokens, **{
|
||||||
|
'host': {
|
||||||
|
'name': _('Hostname'),
|
||||||
|
'type': 'string',
|
||||||
|
},
|
||||||
|
'port': {
|
||||||
|
'name': _('Port'),
|
||||||
|
'type': 'int',
|
||||||
|
'min': 1,
|
||||||
|
'max': 65535,
|
||||||
|
},
|
||||||
|
'pushkey': {
|
||||||
|
'name': _('Pushkey'),
|
||||||
|
'type': 'string',
|
||||||
|
'private': True,
|
||||||
|
'required': True,
|
||||||
|
'regex': (r'^[a-z0-9]+$', 'i'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
def __init__(self, pushkey, **kwargs):
|
||||||
|
"""
|
||||||
|
Initialize PushDeer Object
|
||||||
|
"""
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
# PushKey (associated with project)
|
||||||
|
self.push_key = validate_regex(
|
||||||
|
pushkey, *self.template_tokens['pushkey']['regex'])
|
||||||
|
if not self.push_key:
|
||||||
|
msg = 'An invalid PushDeer API Pushkey ' \
|
||||||
|
'({}) was specified.'.format(pushkey)
|
||||||
|
self.logger.warning(msg)
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||||
|
"""
|
||||||
|
Perform PushDeer Notification
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Prepare our persistent_notification.create payload
|
||||||
|
payload = {
|
||||||
|
'text': title if title else body,
|
||||||
|
'type': 'text',
|
||||||
|
'desp': body if title else '',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set our schema
|
||||||
|
schema = 'https' if self.secure else 'http'
|
||||||
|
|
||||||
|
# Set host
|
||||||
|
host = self.default_hostname
|
||||||
|
if self.host:
|
||||||
|
host = self.host
|
||||||
|
|
||||||
|
# Set port
|
||||||
|
port = 443 if self.secure else 80
|
||||||
|
if self.port:
|
||||||
|
port = self.port
|
||||||
|
|
||||||
|
# Our Notification URL
|
||||||
|
notify_url = self.notify_url.format(
|
||||||
|
schema=schema, host=host, port=port, pushKey=self.push_key)
|
||||||
|
|
||||||
|
# Some Debug Logging
|
||||||
|
self.logger.debug('PushDeer URL: {} (cert_verify={})'.format(
|
||||||
|
notify_url, self.verify_certificate))
|
||||||
|
self.logger.debug('PushDeer Payload: {}'.format(payload))
|
||||||
|
|
||||||
|
# Always call throttle before any remote server i/o is made
|
||||||
|
self.throttle()
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
notify_url,
|
||||||
|
data=payload,
|
||||||
|
timeout=self.request_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
if r.status_code != requests.codes.ok:
|
||||||
|
# We had a problem
|
||||||
|
status_str = \
|
||||||
|
NotifyPushDeer.http_response_code_lookup(
|
||||||
|
r.status_code)
|
||||||
|
|
||||||
|
self.logger.warning(
|
||||||
|
'Failed to send PushDeer notification: '
|
||||||
|
'{}{}error={}.'.format(
|
||||||
|
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 PushDeer notification.')
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
self.logger.warning(
|
||||||
|
'A Connection error occured sending PushDeer '
|
||||||
|
'notification.'
|
||||||
|
)
|
||||||
|
self.logger.debug('Socket Exception: %s' % str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def url(self, privacy=False):
|
||||||
|
"""
|
||||||
|
Returns the URL built dynamically based on specified arguments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.host:
|
||||||
|
url = '{schema}://{host}{port}/{pushkey}'
|
||||||
|
else:
|
||||||
|
url = '{schema}://{pushkey}'
|
||||||
|
|
||||||
|
return url.format(
|
||||||
|
schema=self.secure_protocol if self.secure else self.protocol,
|
||||||
|
host=self.host,
|
||||||
|
port='' if not self.port else ':{}'.format(self.port),
|
||||||
|
pushkey=self.pprint(self.push_key, privacy, safe=''))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_url(url):
|
||||||
|
"""
|
||||||
|
Parses the URL and returns enough arguments that can allow
|
||||||
|
us to substantiate this object.
|
||||||
|
"""
|
||||||
|
results = NotifyBase.parse_url(url, verify_host=False)
|
||||||
|
if not results:
|
||||||
|
# We're done early as we couldn't parse the URL
|
||||||
|
return results
|
||||||
|
|
||||||
|
fullpaths = NotifyPushDeer.split_path(results['fullpath'])
|
||||||
|
|
||||||
|
if len(fullpaths) == 0:
|
||||||
|
results['pushkey'] = results['host']
|
||||||
|
results['host'] = None
|
||||||
|
else:
|
||||||
|
results['pushkey'] = fullpaths.pop()
|
||||||
|
|
||||||
|
return results
|
@ -0,0 +1,221 @@
|
|||||||
|
# -*- 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 requests
|
||||||
|
|
||||||
|
from .NotifyBase import NotifyBase
|
||||||
|
from ..common import NotifyType
|
||||||
|
from ..common import NotifyFormat
|
||||||
|
from ..utils import validate_regex
|
||||||
|
from ..utils import parse_bool
|
||||||
|
from ..AppriseLocale import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class NotifyPushMe(NotifyBase):
|
||||||
|
"""
|
||||||
|
A wrapper for PushMe Notifications
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The default descriptive name associated with the Notification
|
||||||
|
service_name = 'PushMe'
|
||||||
|
|
||||||
|
# The services URL
|
||||||
|
service_url = 'https://push.i-i.me/'
|
||||||
|
|
||||||
|
# Insecure protocol (for those self hosted requests)
|
||||||
|
protocol = 'pushme'
|
||||||
|
|
||||||
|
# A URL that takes you to the setup/help of the specific protocol
|
||||||
|
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushme'
|
||||||
|
|
||||||
|
# PushMe URL
|
||||||
|
notify_url = 'https://push.i-i.me/'
|
||||||
|
|
||||||
|
# Define object templates
|
||||||
|
templates = (
|
||||||
|
'{schema}://{token}',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define our template tokens
|
||||||
|
template_tokens = dict(NotifyBase.template_tokens, **{
|
||||||
|
'token': {
|
||||||
|
'name': _('Token'),
|
||||||
|
'type': 'string',
|
||||||
|
'private': True,
|
||||||
|
'required': True,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
# Define our template arguments
|
||||||
|
template_args = dict(NotifyBase.template_args, **{
|
||||||
|
'token': {
|
||||||
|
'alias_of': 'token',
|
||||||
|
},
|
||||||
|
'push_key': {
|
||||||
|
'alias_of': 'token',
|
||||||
|
},
|
||||||
|
'status': {
|
||||||
|
'name': _('Show Status'),
|
||||||
|
'type': 'bool',
|
||||||
|
'default': True,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
def __init__(self, token, status=None, **kwargs):
|
||||||
|
"""
|
||||||
|
Initialize PushMe Object
|
||||||
|
"""
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
# Token (associated with project)
|
||||||
|
self.token = validate_regex(token)
|
||||||
|
if not self.token:
|
||||||
|
msg = 'An invalid PushMe Token ' \
|
||||||
|
'({}) was specified.'.format(token)
|
||||||
|
self.logger.warning(msg)
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
# Set Status type
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||||
|
"""
|
||||||
|
Perform PushMe Notification
|
||||||
|
"""
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': self.app_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prepare our payload
|
||||||
|
params = {
|
||||||
|
'push_key': self.token,
|
||||||
|
'title': title if not self.status
|
||||||
|
else '{} {}'.format(self.asset.ascii(notify_type), title),
|
||||||
|
'content': body,
|
||||||
|
'type': 'markdown'
|
||||||
|
if self.notify_format == NotifyFormat.MARKDOWN else 'text'
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logger.debug('PushMe POST URL: %s (cert_verify=%r)' % (
|
||||||
|
self.notify_url, self.verify_certificate,
|
||||||
|
))
|
||||||
|
self.logger.debug('PushMe Payload: %s' % str(params))
|
||||||
|
|
||||||
|
# Always call throttle before any remote server i/o is made
|
||||||
|
self.throttle()
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
self.notify_url,
|
||||||
|
params=params,
|
||||||
|
headers=headers,
|
||||||
|
verify=self.verify_certificate,
|
||||||
|
timeout=self.request_timeout,
|
||||||
|
)
|
||||||
|
if r.status_code != requests.codes.ok:
|
||||||
|
# We had a problem
|
||||||
|
status_str = \
|
||||||
|
NotifyPushMe.http_response_code_lookup(r.status_code)
|
||||||
|
|
||||||
|
self.logger.warning(
|
||||||
|
'Failed to send PushMe notification:'
|
||||||
|
'{}{}error={}.'.format(
|
||||||
|
status_str,
|
||||||
|
', ' if status_str else '',
|
||||||
|
r.status_code))
|
||||||
|
|
||||||
|
self.logger.debug('Response Details:\r\n{}'.format(r.content))
|
||||||
|
|
||||||
|
# Return; we're done
|
||||||
|
return False
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.logger.info('Sent PushMe notification.')
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
self.logger.warning(
|
||||||
|
'A Connection error occurred sending PushMe notification.',
|
||||||
|
)
|
||||||
|
self.logger.debug('Socket Exception: %s' % str(e))
|
||||||
|
|
||||||
|
# Return; we're done
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def url(self, privacy=False, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Returns the URL built dynamically based on specified arguments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Define any URL parameters
|
||||||
|
params = {
|
||||||
|
'status': 'yes' if self.status else 'no',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extend our parameters
|
||||||
|
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
|
||||||
|
|
||||||
|
# Official URLs are easy to assemble
|
||||||
|
return '{schema}://{token}/?{params}'.format(
|
||||||
|
schema=self.protocol,
|
||||||
|
token=self.pprint(self.token, privacy, safe=''),
|
||||||
|
params=NotifyPushMe.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
|
||||||
|
|
||||||
|
# Store our token using the host
|
||||||
|
results['token'] = NotifyPushMe.unquote(results['host'])
|
||||||
|
|
||||||
|
# The 'token' makes it easier to use yaml configuration
|
||||||
|
if 'token' in results['qsd'] and len(results['qsd']['token']):
|
||||||
|
results['token'] = NotifyPushMe.unquote(results['qsd']['token'])
|
||||||
|
|
||||||
|
elif 'push_key' in results['qsd'] and len(results['qsd']['push_key']):
|
||||||
|
# Support 'push_key' if specified
|
||||||
|
results['token'] = NotifyPushMe.unquote(results['qsd']['push_key'])
|
||||||
|
|
||||||
|
# Get status switch
|
||||||
|
results['status'] = \
|
||||||
|
parse_bool(results['qsd'].get('status', True))
|
||||||
|
|
||||||
|
return results
|
@ -0,0 +1,384 @@
|
|||||||
|
# -*- 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.
|
||||||
|
|
||||||
|
# API reference: https://pushy.me/docs/api/send-notifications
|
||||||
|
import re
|
||||||
|
import requests
|
||||||
|
from itertools import chain
|
||||||
|
|
||||||
|
from json import dumps, loads
|
||||||
|
from .NotifyBase import NotifyBase
|
||||||
|
from ..common import NotifyType
|
||||||
|
from ..utils import parse_list
|
||||||
|
from ..utils import validate_regex
|
||||||
|
from ..AppriseLocale import gettext_lazy as _
|
||||||
|
|
||||||
|
# Used to detect a Device and Topic
|
||||||
|
VALIDATE_DEVICE = re.compile(r'^@(?P<device>[a-z0-9]+)$', re.I)
|
||||||
|
VALIDATE_TOPIC = re.compile(r'^[#]?(?P<topic>[a-z0-9]+)$', re.I)
|
||||||
|
|
||||||
|
# Extend HTTP Error Messages
|
||||||
|
PUSHY_HTTP_ERROR_MAP = {
|
||||||
|
401: 'Unauthorized - Invalid Token.',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class NotifyPushy(NotifyBase):
|
||||||
|
"""
|
||||||
|
A wrapper for Pushy Notifications
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The default descriptive name associated with the Notification
|
||||||
|
service_name = 'Pushy'
|
||||||
|
|
||||||
|
# The services URL
|
||||||
|
service_url = 'https://pushy.me/'
|
||||||
|
|
||||||
|
# All Pushy requests are secure
|
||||||
|
secure_protocol = 'pushy'
|
||||||
|
|
||||||
|
# A URL that takes you to the setup/help of the specific protocol
|
||||||
|
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushy'
|
||||||
|
|
||||||
|
# Pushy uses the http protocol with JSON requests
|
||||||
|
notify_url = 'https://api.pushy.me/push?api_key={apikey}'
|
||||||
|
|
||||||
|
# The maximum allowable characters allowed in the body per message
|
||||||
|
body_maxlen = 4096
|
||||||
|
|
||||||
|
# Define object templates
|
||||||
|
templates = (
|
||||||
|
'{schema}://{apikey}/{targets}',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define our template tokens
|
||||||
|
template_tokens = dict(NotifyBase.template_tokens, **{
|
||||||
|
'apikey': {
|
||||||
|
'name': _('Secret API Key'),
|
||||||
|
'type': 'string',
|
||||||
|
'private': True,
|
||||||
|
'required': True,
|
||||||
|
},
|
||||||
|
'target_device': {
|
||||||
|
'name': _('Target Device'),
|
||||||
|
'type': 'string',
|
||||||
|
'prefix': '@',
|
||||||
|
'map_to': 'targets',
|
||||||
|
},
|
||||||
|
'target_topic': {
|
||||||
|
'name': _('Target Topic'),
|
||||||
|
'type': 'string',
|
||||||
|
'prefix': '#',
|
||||||
|
'map_to': 'targets',
|
||||||
|
},
|
||||||
|
'targets': {
|
||||||
|
'name': _('Targets'),
|
||||||
|
'type': 'list:string',
|
||||||
|
'required': True,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
# Define our template arguments
|
||||||
|
template_args = dict(NotifyBase.template_args, **{
|
||||||
|
'sound': {
|
||||||
|
# Specify something like ping.aiff
|
||||||
|
'name': _('Sound'),
|
||||||
|
'type': 'string',
|
||||||
|
},
|
||||||
|
'badge': {
|
||||||
|
'name': _('Badge'),
|
||||||
|
'type': 'int',
|
||||||
|
'min': 0,
|
||||||
|
},
|
||||||
|
'to': {
|
||||||
|
'alias_of': 'targets',
|
||||||
|
},
|
||||||
|
'key': {
|
||||||
|
'alias_of': 'apikey',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
def __init__(self, apikey, targets=None, sound=None, badge=None, **kwargs):
|
||||||
|
"""
|
||||||
|
Initialize Pushy Object
|
||||||
|
"""
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
# Access Token (associated with project)
|
||||||
|
self.apikey = validate_regex(apikey)
|
||||||
|
if not self.apikey:
|
||||||
|
msg = 'An invalid Pushy Secret API Key ' \
|
||||||
|
'({}) was specified.'.format(apikey)
|
||||||
|
self.logger.warning(msg)
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
# Get our targets
|
||||||
|
self.devices = []
|
||||||
|
self.topics = []
|
||||||
|
|
||||||
|
for target in parse_list(targets):
|
||||||
|
result = VALIDATE_TOPIC.match(target)
|
||||||
|
if result:
|
||||||
|
self.topics.append(result.group('topic'))
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = VALIDATE_DEVICE.match(target)
|
||||||
|
if result:
|
||||||
|
self.devices.append(result.group('device'))
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.logger.warning(
|
||||||
|
'Dropped invalid topic/device '
|
||||||
|
'({}) specified.'.format(target),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Setup our sound
|
||||||
|
self.sound = sound
|
||||||
|
|
||||||
|
# Badge
|
||||||
|
try:
|
||||||
|
# Acquire our badge count if we can:
|
||||||
|
# - We accept both the integer form as well as a string
|
||||||
|
# representation
|
||||||
|
self.badge = int(badge)
|
||||||
|
if self.badge < 0:
|
||||||
|
raise ValueError()
|
||||||
|
|
||||||
|
except TypeError:
|
||||||
|
# NoneType means use Default; this is an okay exception
|
||||||
|
self.badge = None
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
self.badge = None
|
||||||
|
self.logger.warning(
|
||||||
|
'The specified Pushy badge ({}) is not valid ', badge)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||||
|
"""
|
||||||
|
Perform Pushy Notification
|
||||||
|
"""
|
||||||
|
|
||||||
|
if len(self.topics) + len(self.devices) == 0:
|
||||||
|
# There were no services to notify
|
||||||
|
self.logger.warning('There were no Pushy targets to notify.')
|
||||||
|
return False
|
||||||
|
|
||||||
|
# error tracking (used for function return)
|
||||||
|
has_error = False
|
||||||
|
|
||||||
|
# Default Header
|
||||||
|
headers = {
|
||||||
|
'User-Agent': self.app_id,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accepts': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Our URL
|
||||||
|
notify_url = self.notify_url.format(apikey=self.apikey)
|
||||||
|
|
||||||
|
# Default content response object
|
||||||
|
content = {}
|
||||||
|
|
||||||
|
# Create a copy of targets (topics and devices)
|
||||||
|
targets = list(self.topics) + list(self.devices)
|
||||||
|
while len(targets):
|
||||||
|
target = targets.pop(0)
|
||||||
|
|
||||||
|
# prepare JSON Object
|
||||||
|
payload = {
|
||||||
|
# Mandatory fields
|
||||||
|
'to': target,
|
||||||
|
"data": {
|
||||||
|
"message": body,
|
||||||
|
},
|
||||||
|
"notification": {
|
||||||
|
'body': body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional payload items
|
||||||
|
if title:
|
||||||
|
payload['notification']['title'] = title
|
||||||
|
|
||||||
|
if self.sound:
|
||||||
|
payload['notification']['sound'] = self.sound
|
||||||
|
|
||||||
|
if self.badge is not None:
|
||||||
|
payload['notification']['badge'] = self.badge
|
||||||
|
|
||||||
|
self.logger.debug('Pushy POST URL: %s (cert_verify=%r)' % (
|
||||||
|
notify_url, self.verify_certificate,
|
||||||
|
))
|
||||||
|
self.logger.debug('Pushy Payload: %s' % str(payload))
|
||||||
|
|
||||||
|
# Always call throttle before any remote server i/o is made
|
||||||
|
self.throttle()
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
notify_url,
|
||||||
|
data=dumps(payload),
|
||||||
|
headers=headers,
|
||||||
|
verify=self.verify_certificate,
|
||||||
|
timeout=self.request_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sample response
|
||||||
|
# See: https://pushy.me/docs/api/send-notifications
|
||||||
|
# {
|
||||||
|
# "success": true,
|
||||||
|
# "id": "5ea9b214b47cad768a35f13a",
|
||||||
|
# "info": {
|
||||||
|
# "devices": 1
|
||||||
|
# "failed": ['abc']
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
try:
|
||||||
|
content = loads(r.content)
|
||||||
|
|
||||||
|
except (AttributeError, TypeError, ValueError):
|
||||||
|
# ValueError = r.content is Unparsable
|
||||||
|
# TypeError = r.content is None
|
||||||
|
# AttributeError = r is None
|
||||||
|
content = {
|
||||||
|
"success": False,
|
||||||
|
"id": '',
|
||||||
|
"info": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.status_code != requests.codes.ok \
|
||||||
|
or not content.get('success'):
|
||||||
|
|
||||||
|
# We had a problem
|
||||||
|
status_str = \
|
||||||
|
NotifyPushy.http_response_code_lookup(
|
||||||
|
r.status_code, PUSHY_HTTP_ERROR_MAP)
|
||||||
|
|
||||||
|
self.logger.warning(
|
||||||
|
'Failed to send Pushy notification to {}: '
|
||||||
|
'{}{}error={}.'.format(
|
||||||
|
target,
|
||||||
|
status_str,
|
||||||
|
', ' if status_str else '',
|
||||||
|
r.status_code))
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
'Response Details:\r\n{}'.format(r.content))
|
||||||
|
|
||||||
|
has_error = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.logger.info(
|
||||||
|
'Sent Pushy notification to %s.' % target)
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
self.logger.warning(
|
||||||
|
'A Connection error occurred sending Pushy:%s '
|
||||||
|
'notification', target)
|
||||||
|
self.logger.debug('Socket Exception: %s' % str(e))
|
||||||
|
|
||||||
|
has_error = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
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 = {}
|
||||||
|
if self.sound:
|
||||||
|
params['sound'] = self.sound
|
||||||
|
|
||||||
|
if self.badge is not None:
|
||||||
|
params['badge'] = str(self.badge)
|
||||||
|
|
||||||
|
# Extend our parameters
|
||||||
|
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
|
||||||
|
|
||||||
|
return '{schema}://{apikey}/{targets}/?{params}'.format(
|
||||||
|
schema=self.secure_protocol,
|
||||||
|
apikey=self.pprint(self.apikey, privacy, safe=''),
|
||||||
|
targets='/'.join(
|
||||||
|
[NotifyPushy.quote(x, safe='@#') for x in chain(
|
||||||
|
# Topics are prefixed with a pound/hashtag symbol
|
||||||
|
['#{}'.format(x) for x in self.topics],
|
||||||
|
# Devices
|
||||||
|
['@{}'.format(x) for x in self.devices],
|
||||||
|
)]),
|
||||||
|
params=NotifyPushy.urlencode(params))
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
"""
|
||||||
|
Returns the number of targets associated with this notification
|
||||||
|
"""
|
||||||
|
return len(self.topics) + len(self.devices)
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
# Token
|
||||||
|
results['apikey'] = NotifyPushy.unquote(results['host'])
|
||||||
|
|
||||||
|
# Retrieve all of our targets
|
||||||
|
results['targets'] = NotifyPushy.split_path(results['fullpath'])
|
||||||
|
|
||||||
|
# Get the sound
|
||||||
|
if 'sound' in results['qsd'] and len(results['qsd']['sound']):
|
||||||
|
results['sound'] = \
|
||||||
|
NotifyPushy.unquote(results['qsd']['sound'])
|
||||||
|
|
||||||
|
# Badge
|
||||||
|
if 'badge' in results['qsd'] and results['qsd']['badge']:
|
||||||
|
results['badge'] = NotifyPushy.unquote(
|
||||||
|
results['qsd']['badge'].strip())
|
||||||
|
|
||||||
|
# Support key variable to store Secret API Key
|
||||||
|
if 'key' in results['qsd'] and len(results['qsd']['key']):
|
||||||
|
results['apikey'] = results['qsd']['key']
|
||||||
|
|
||||||
|
# The 'to' makes it easier to use yaml configuration
|
||||||
|
if 'to' in results['qsd'] and len(results['qsd']['to']):
|
||||||
|
results['targets'] += \
|
||||||
|
NotifyPushy.parse_list(results['qsd']['to'])
|
||||||
|
|
||||||
|
return results
|
@ -0,0 +1,376 @@
|
|||||||
|
# -*- 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 os
|
||||||
|
import socket
|
||||||
|
|
||||||
|
from .NotifyBase import NotifyBase
|
||||||
|
from ..common import NotifyType
|
||||||
|
from ..utils import parse_bool
|
||||||
|
from ..AppriseLocale import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class syslog:
|
||||||
|
"""
|
||||||
|
Extrapoloated information from the syslog library so that this plugin
|
||||||
|
would not be dependent on it.
|
||||||
|
"""
|
||||||
|
# Notification Categories
|
||||||
|
LOG_KERN = 0
|
||||||
|
LOG_USER = 8
|
||||||
|
LOG_MAIL = 16
|
||||||
|
LOG_DAEMON = 24
|
||||||
|
LOG_AUTH = 32
|
||||||
|
LOG_SYSLOG = 40
|
||||||
|
LOG_LPR = 48
|
||||||
|
LOG_NEWS = 56
|
||||||
|
LOG_UUCP = 64
|
||||||
|
LOG_CRON = 72
|
||||||
|
LOG_LOCAL0 = 128
|
||||||
|
LOG_LOCAL1 = 136
|
||||||
|
LOG_LOCAL2 = 144
|
||||||
|
LOG_LOCAL3 = 152
|
||||||
|
LOG_LOCAL4 = 160
|
||||||
|
LOG_LOCAL5 = 168
|
||||||
|
LOG_LOCAL6 = 176
|
||||||
|
LOG_LOCAL7 = 184
|
||||||
|
|
||||||
|
# Notification Types
|
||||||
|
LOG_INFO = 6
|
||||||
|
LOG_NOTICE = 5
|
||||||
|
LOG_WARNING = 4
|
||||||
|
LOG_CRIT = 2
|
||||||
|
|
||||||
|
|
||||||
|
class SyslogFacility:
|
||||||
|
"""
|
||||||
|
All of the supported facilities
|
||||||
|
"""
|
||||||
|
KERN = 'kern'
|
||||||
|
USER = 'user'
|
||||||
|
MAIL = 'mail'
|
||||||
|
DAEMON = 'daemon'
|
||||||
|
AUTH = 'auth'
|
||||||
|
SYSLOG = 'syslog'
|
||||||
|
LPR = 'lpr'
|
||||||
|
NEWS = 'news'
|
||||||
|
UUCP = 'uucp'
|
||||||
|
CRON = 'cron'
|
||||||
|
LOCAL0 = 'local0'
|
||||||
|
LOCAL1 = 'local1'
|
||||||
|
LOCAL2 = 'local2'
|
||||||
|
LOCAL3 = 'local3'
|
||||||
|
LOCAL4 = 'local4'
|
||||||
|
LOCAL5 = 'local5'
|
||||||
|
LOCAL6 = 'local6'
|
||||||
|
LOCAL7 = 'local7'
|
||||||
|
|
||||||
|
|
||||||
|
SYSLOG_FACILITY_MAP = {
|
||||||
|
SyslogFacility.KERN: syslog.LOG_KERN,
|
||||||
|
SyslogFacility.USER: syslog.LOG_USER,
|
||||||
|
SyslogFacility.MAIL: syslog.LOG_MAIL,
|
||||||
|
SyslogFacility.DAEMON: syslog.LOG_DAEMON,
|
||||||
|
SyslogFacility.AUTH: syslog.LOG_AUTH,
|
||||||
|
SyslogFacility.SYSLOG: syslog.LOG_SYSLOG,
|
||||||
|
SyslogFacility.LPR: syslog.LOG_LPR,
|
||||||
|
SyslogFacility.NEWS: syslog.LOG_NEWS,
|
||||||
|
SyslogFacility.UUCP: syslog.LOG_UUCP,
|
||||||
|
SyslogFacility.CRON: syslog.LOG_CRON,
|
||||||
|
SyslogFacility.LOCAL0: syslog.LOG_LOCAL0,
|
||||||
|
SyslogFacility.LOCAL1: syslog.LOG_LOCAL1,
|
||||||
|
SyslogFacility.LOCAL2: syslog.LOG_LOCAL2,
|
||||||
|
SyslogFacility.LOCAL3: syslog.LOG_LOCAL3,
|
||||||
|
SyslogFacility.LOCAL4: syslog.LOG_LOCAL4,
|
||||||
|
SyslogFacility.LOCAL5: syslog.LOG_LOCAL5,
|
||||||
|
SyslogFacility.LOCAL6: syslog.LOG_LOCAL6,
|
||||||
|
SyslogFacility.LOCAL7: syslog.LOG_LOCAL7,
|
||||||
|
}
|
||||||
|
|
||||||
|
SYSLOG_FACILITY_RMAP = {
|
||||||
|
syslog.LOG_KERN: SyslogFacility.KERN,
|
||||||
|
syslog.LOG_USER: SyslogFacility.USER,
|
||||||
|
syslog.LOG_MAIL: SyslogFacility.MAIL,
|
||||||
|
syslog.LOG_DAEMON: SyslogFacility.DAEMON,
|
||||||
|
syslog.LOG_AUTH: SyslogFacility.AUTH,
|
||||||
|
syslog.LOG_SYSLOG: SyslogFacility.SYSLOG,
|
||||||
|
syslog.LOG_LPR: SyslogFacility.LPR,
|
||||||
|
syslog.LOG_NEWS: SyslogFacility.NEWS,
|
||||||
|
syslog.LOG_UUCP: SyslogFacility.UUCP,
|
||||||
|
syslog.LOG_CRON: SyslogFacility.CRON,
|
||||||
|
syslog.LOG_LOCAL0: SyslogFacility.LOCAL0,
|
||||||
|
syslog.LOG_LOCAL1: SyslogFacility.LOCAL1,
|
||||||
|
syslog.LOG_LOCAL2: SyslogFacility.LOCAL2,
|
||||||
|
syslog.LOG_LOCAL3: SyslogFacility.LOCAL3,
|
||||||
|
syslog.LOG_LOCAL4: SyslogFacility.LOCAL4,
|
||||||
|
syslog.LOG_LOCAL5: SyslogFacility.LOCAL5,
|
||||||
|
syslog.LOG_LOCAL6: SyslogFacility.LOCAL6,
|
||||||
|
syslog.LOG_LOCAL7: SyslogFacility.LOCAL7,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Used as a lookup when handling the Apprise -> Syslog Mapping
|
||||||
|
SYSLOG_PUBLISH_MAP = {
|
||||||
|
NotifyType.INFO: syslog.LOG_INFO,
|
||||||
|
NotifyType.SUCCESS: syslog.LOG_NOTICE,
|
||||||
|
NotifyType.FAILURE: syslog.LOG_CRIT,
|
||||||
|
NotifyType.WARNING: syslog.LOG_WARNING,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class NotifyRSyslog(NotifyBase):
|
||||||
|
"""
|
||||||
|
A wrapper for Remote Syslog Notifications
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The default descriptive name associated with the Notification
|
||||||
|
service_name = 'Remote Syslog'
|
||||||
|
|
||||||
|
# The services URL
|
||||||
|
service_url = 'https://tools.ietf.org/html/rfc5424'
|
||||||
|
|
||||||
|
# The default protocol
|
||||||
|
protocol = 'rsyslog'
|
||||||
|
|
||||||
|
# A URL that takes you to the setup/help of the specific protocol
|
||||||
|
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_rsyslog'
|
||||||
|
|
||||||
|
# Disable throttle rate for RSyslog requests
|
||||||
|
request_rate_per_sec = 0
|
||||||
|
|
||||||
|
# Define object templates
|
||||||
|
templates = (
|
||||||
|
'{schema}://{host}',
|
||||||
|
'{schema}://{host}:{port}',
|
||||||
|
'{schema}://{host}/{facility}',
|
||||||
|
'{schema}://{host}:{port}/{facility}',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define our template tokens
|
||||||
|
template_tokens = dict(NotifyBase.template_tokens, **{
|
||||||
|
'facility': {
|
||||||
|
'name': _('Facility'),
|
||||||
|
'type': 'choice:string',
|
||||||
|
'values': [k for k in SYSLOG_FACILITY_MAP.keys()],
|
||||||
|
'default': SyslogFacility.USER,
|
||||||
|
'required': True,
|
||||||
|
},
|
||||||
|
'host': {
|
||||||
|
'name': _('Hostname'),
|
||||||
|
'type': 'string',
|
||||||
|
'required': True,
|
||||||
|
},
|
||||||
|
'port': {
|
||||||
|
'name': _('Port'),
|
||||||
|
'type': 'int',
|
||||||
|
'min': 1,
|
||||||
|
'max': 65535,
|
||||||
|
'default': 514,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
# Define our template arguments
|
||||||
|
template_args = dict(NotifyBase.template_args, **{
|
||||||
|
'facility': {
|
||||||
|
# We map back to the same element defined in template_tokens
|
||||||
|
'alias_of': 'facility',
|
||||||
|
},
|
||||||
|
'logpid': {
|
||||||
|
'name': _('Log PID'),
|
||||||
|
'type': 'bool',
|
||||||
|
'default': True,
|
||||||
|
'map_to': 'log_pid',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
def __init__(self, facility=None, log_pid=True, **kwargs):
|
||||||
|
"""
|
||||||
|
Initialize RSyslog Object
|
||||||
|
"""
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
if facility:
|
||||||
|
try:
|
||||||
|
self.facility = SYSLOG_FACILITY_MAP[facility]
|
||||||
|
|
||||||
|
except KeyError:
|
||||||
|
msg = 'An invalid syslog facility ' \
|
||||||
|
'({}) was specified.'.format(facility)
|
||||||
|
self.logger.warning(msg)
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.facility = \
|
||||||
|
SYSLOG_FACILITY_MAP[
|
||||||
|
self.template_tokens['facility']['default']]
|
||||||
|
|
||||||
|
# Include PID with each message.
|
||||||
|
self.log_pid = log_pid
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||||
|
"""
|
||||||
|
Perform RSyslog Notification
|
||||||
|
"""
|
||||||
|
|
||||||
|
if title:
|
||||||
|
# Format title
|
||||||
|
body = '{}: {}'.format(title, body)
|
||||||
|
|
||||||
|
# Always call throttle before any remote server i/o is made
|
||||||
|
self.throttle()
|
||||||
|
host = self.host
|
||||||
|
port = self.port if self.port \
|
||||||
|
else self.template_tokens['port']['default']
|
||||||
|
|
||||||
|
if self.log_pid:
|
||||||
|
payload = '<%d>- %d - %s' % (
|
||||||
|
SYSLOG_PUBLISH_MAP[notify_type] + self.facility * 8,
|
||||||
|
os.getpid(), body)
|
||||||
|
|
||||||
|
else:
|
||||||
|
payload = '<%d>- %s' % (
|
||||||
|
SYSLOG_PUBLISH_MAP[notify_type] + self.facility * 8, body)
|
||||||
|
|
||||||
|
# send UDP packet to upstream server
|
||||||
|
self.logger.debug(
|
||||||
|
'RSyslog Host: %s:%d/%s',
|
||||||
|
host, port, SYSLOG_FACILITY_RMAP[self.facility])
|
||||||
|
self.logger.debug('RSyslog Payload: %s' % str(payload))
|
||||||
|
|
||||||
|
# our sent bytes
|
||||||
|
sent = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sock.settimeout(self.socket_connect_timeout)
|
||||||
|
sent = sock.sendto(payload.encode('utf-8'), (host, port))
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
except socket.gaierror as e:
|
||||||
|
self.logger.warning(
|
||||||
|
'A connection error occurred sending RSyslog '
|
||||||
|
'notification to %s:%d/%s', host, port,
|
||||||
|
SYSLOG_FACILITY_RMAP[self.facility]
|
||||||
|
)
|
||||||
|
self.logger.debug('Socket Exception: %s' % str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
except socket.timeout as e:
|
||||||
|
self.logger.warning(
|
||||||
|
'A connection timeout occurred sending RSyslog '
|
||||||
|
'notification to %s:%d/%s', host, port,
|
||||||
|
SYSLOG_FACILITY_RMAP[self.facility]
|
||||||
|
)
|
||||||
|
self.logger.debug('Socket Exception: %s' % str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
if sent < len(payload):
|
||||||
|
self.logger.warning(
|
||||||
|
'RSyslog sent %d byte(s) but intended to send %d byte(s)',
|
||||||
|
sent, len(payload))
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.logger.info('Sent RSyslog notification.')
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def url(self, privacy=False, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Returns the URL built dynamically based on specified arguments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Define any URL parameters
|
||||||
|
params = {
|
||||||
|
'logpid': 'yes' if self.log_pid else 'no',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extend our parameters
|
||||||
|
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
|
||||||
|
|
||||||
|
return '{schema}://{hostname}{port}/{facility}/?{params}'.format(
|
||||||
|
schema=self.protocol,
|
||||||
|
hostname=NotifyRSyslog.quote(self.host, safe=''),
|
||||||
|
port='' if self.port is None
|
||||||
|
or self.port == self.template_tokens['port']['default']
|
||||||
|
else ':{}'.format(self.port),
|
||||||
|
facility=self.template_tokens['facility']['default']
|
||||||
|
if self.facility not in SYSLOG_FACILITY_RMAP
|
||||||
|
else SYSLOG_FACILITY_RMAP[self.facility],
|
||||||
|
params=NotifyRSyslog.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
|
||||||
|
|
||||||
|
tokens = []
|
||||||
|
|
||||||
|
# Get our path values
|
||||||
|
tokens.extend(NotifyRSyslog.split_path(results['fullpath']))
|
||||||
|
|
||||||
|
# Initialization
|
||||||
|
facility = None
|
||||||
|
|
||||||
|
if tokens:
|
||||||
|
# Store the last entry as the facility
|
||||||
|
facility = tokens[-1].lower()
|
||||||
|
|
||||||
|
# However if specified on the URL, that will over-ride what was
|
||||||
|
# identified
|
||||||
|
if 'facility' in results['qsd'] and len(results['qsd']['facility']):
|
||||||
|
facility = results['qsd']['facility'].lower()
|
||||||
|
|
||||||
|
if facility and facility not in SYSLOG_FACILITY_MAP:
|
||||||
|
# Find first match; if no match is found we set the result
|
||||||
|
# to the matching key. This allows us to throw a TypeError
|
||||||
|
# during the __init__() call. The benifit of doing this
|
||||||
|
# check here is if we do have a valid match, we can support
|
||||||
|
# short form matches like 'u' which will match against user
|
||||||
|
facility = next((f for f in SYSLOG_FACILITY_MAP.keys()
|
||||||
|
if f.startswith(facility)), facility)
|
||||||
|
|
||||||
|
# Save facility if set
|
||||||
|
if facility:
|
||||||
|
results['facility'] = facility
|
||||||
|
|
||||||
|
# Include PID as part of the message logged
|
||||||
|
results['log_pid'] = parse_bool(
|
||||||
|
results['qsd'].get(
|
||||||
|
'logpid',
|
||||||
|
NotifyRSyslog.template_args['logpid']['default']))
|
||||||
|
|
||||||
|
return results
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue