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