diff --git a/KEYWORDS b/KEYWORDS index 6ce9ca9d..cbd6feee 100644 --- a/KEYWORDS +++ b/KEYWORDS @@ -3,7 +3,6 @@ Alerts Apprise API Automated Packet Reporting System AWS -Boxcar BulkSMS BulkVS Burst SMS diff --git a/README.md b/README.md index 3e0f7f3f..84b914bd 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,6 @@ The table below identifies the services this tool supports and some example serv | [Apprise API](https://github.com/caronc/apprise/wiki/Notify_apprise_api) | apprise:// or apprises:// | (TCP) 80 or 443 | apprise://hostname/Token | [AWS SES](https://github.com/caronc/apprise/wiki/Notify_ses) | ses:// | (TCP) 443 | ses://user@domain/AccessKeyID/AccessSecretKey/RegionName
ses://user@domain/AccessKeyID/AccessSecretKey/RegionName/email1/email2/emailN | [Bark](https://github.com/caronc/apprise/wiki/Notify_bark) | bark:// | (TCP) 80 or 443 | bark://hostname
bark://hostname/device_key
bark://hostname/device_key1/device_key2/device_keyN
barks://hostname
barks://hostname/device_key
barks://hostname/device_key1/device_key2/device_keyN -| [Boxcar](https://github.com/caronc/apprise/wiki/Notify_boxcar) | boxcar:// | (TCP) 443 | boxcar://hostname
boxcar://hostname/@tag
boxcar://hostname/device_token
boxcar://hostname/device_token1/device_token2/device_tokenN
boxcar://hostname/@tag/@tag2/device_token | [Chantify](https://github.com/caronc/apprise/wiki/Notify_chantify) | chantify:// | (TCP) 443 | chantify://token | [Discord](https://github.com/caronc/apprise/wiki/Notify_discord) | discord:// | (TCP) 443 | discord://webhook_id/webhook_token
discord://avatar@webhook_id/webhook_token | [Emby](https://github.com/caronc/apprise/wiki/Notify_emby) | emby:// or embys:// | (TCP) 8096 | emby://user@hostname/
emby://user:password@hostname diff --git a/apprise/plugins/boxcar.py b/apprise/plugins/boxcar.py deleted file mode 100644 index f7f16b04..00000000 --- a/apprise/plugins/boxcar.py +++ /dev/null @@ -1,404 +0,0 @@ -# -*- coding: utf-8 -*- -# BSD 2-Clause License -# -# Apprise - Push Notification Library. -# Copyright (c) 2024, Chris Caron -# -# 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 -import hmac -from json import dumps -from time import time -from hashlib import sha1 -from itertools import chain -from urllib.parse import urlparse - -from .base import NotifyBase -from ..url import PrivacyMode -from ..utils import parse_bool -from ..utils import parse_list -from ..utils import validate_regex -from ..common import NotifyType -from ..common import NotifyImageSize -from ..locale import gettext_lazy as _ - -# Default to sending to all devices if nothing is specified -DEFAULT_TAG = '@all' - -# The tags value is an structure containing an array of strings defining the -# list of tagged devices that the notification need to be send to, and a -# boolean operator (‘and’ / ‘or’) that defines the criteria to match devices -# against those tags. -IS_TAG = re.compile(r'^[@]?(?P[A-Z0-9]{1,63})$', re.I) - -# Device tokens are only referenced when developing. -# It's not likely you'll send a message directly to a device, but if you do; -# this plugin supports it. -IS_DEVICETOKEN = re.compile(r'^[A-Z0-9]{64}$', re.I) - -# Used to break apart list of potential tags by their delimiter into a useable -# list. -TAGS_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') - - -class NotifyBoxcar(NotifyBase): - """ - A wrapper for Boxcar Notifications - """ - - # The default descriptive name associated with the Notification - service_name = 'Boxcar' - - # The services URL - service_url = 'https://boxcar.io/' - - # All boxcar notifications are secure - secure_protocol = 'boxcar' - - # A URL that takes you to the setup/help of the specific protocol - setup_url = 'https://github.com/caronc/apprise/wiki/Notify_boxcar' - - # Boxcar URL - notify_url = 'https://boxcar-api.io/api/push/' - - # 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 = 10000 - - # Define object templates - templates = ( - '{schema}://{access_key}/{secret_key}/', - '{schema}://{access_key}/{secret_key}/{targets}', - ) - - # Define our template tokens - template_tokens = dict(NotifyBase.template_tokens, **{ - 'access_key': { - 'name': _('Access Key'), - 'type': 'string', - 'private': True, - 'required': True, - 'regex': (r'^[A-Z0-9_-]{64}$', 'i'), - 'map_to': 'access', - }, - 'secret_key': { - 'name': _('Secret Key'), - 'type': 'string', - 'private': True, - 'required': True, - 'regex': (r'^[A-Z0-9_-]{64}$', 'i'), - 'map_to': 'secret', - }, - 'target_tag': { - 'name': _('Target Tag ID'), - 'type': 'string', - 'prefix': '@', - 'regex': (r'^[A-Z0-9]{1,63}$', 'i'), - 'map_to': 'targets', - }, - 'target_device': { - 'name': _('Target Device ID'), - 'type': 'string', - 'regex': (r'^[A-Z0-9]{64}$', 'i'), - 'map_to': 'targets', - }, - 'targets': { - 'name': _('Targets'), - 'type': 'list:string', - }, - }) - - # Define our template arguments - template_args = dict(NotifyBase.template_args, **{ - 'image': { - 'name': _('Include Image'), - 'type': 'bool', - 'default': True, - 'map_to': 'include_image', - }, - 'to': { - 'alias_of': 'targets', - }, - 'access': { - 'alias_of': 'access_key', - }, - 'secret': { - 'alias_of': 'secret_key', - }, - }) - - def __init__(self, access, secret, targets=None, include_image=True, - **kwargs): - """ - Initialize Boxcar Object - """ - super().__init__(**kwargs) - - # Initialize tag list - self._tags = list() - - # Initialize device_token list - self.device_tokens = list() - - # Access Key (associated with project) - self.access = validate_regex( - access, *self.template_tokens['access_key']['regex']) - if not self.access: - msg = 'An invalid Boxcar Access Key ' \ - '({}) was specified.'.format(access) - self.logger.warning(msg) - raise TypeError(msg) - - # Secret Key (associated with project) - self.secret = validate_regex( - secret, *self.template_tokens['secret_key']['regex']) - if not self.secret: - msg = 'An invalid Boxcar Secret Key ' \ - '({}) was specified.'.format(secret) - self.logger.warning(msg) - raise TypeError(msg) - - if not targets: - self._tags.append(DEFAULT_TAG) - targets = [] - - # Validate targets and drop bad ones: - for target in parse_list(targets): - result = IS_TAG.match(target) - if result: - # store valid tag/alias - self._tags.append(result.group('name')) - continue - - result = IS_DEVICETOKEN.match(target) - if result: - # store valid device - self.device_tokens.append(target) - continue - - self.logger.warning( - 'Dropped invalid tag/alias/device_token ' - '({}) specified.'.format(target), - ) - - # 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 Boxcar Notification - """ - headers = { - 'User-Agent': self.app_id, - 'Content-Type': 'application/json' - } - - # prepare Boxcar Object - payload = { - 'aps': { - 'badge': 'auto', - 'alert': '', - }, - 'expires': str(int(time() + 30)), - } - - if title: - payload['aps']['@title'] = title - - payload['aps']['alert'] = body - - if self._tags: - payload['tags'] = {'or': self._tags} - - if self.device_tokens: - payload['device_tokens'] = self.device_tokens - - # Source picture should be <= 450 DP wide, ~2:1 aspect. - image_url = None if not self.include_image \ - else self.image_url(notify_type) - - if image_url: - # Set our image - payload['@img'] = image_url - - # Acquire our hostname - host = urlparse(self.notify_url).hostname - - # Calculate signature. - str_to_sign = "%s\n%s\n%s\n%s" % ( - "POST", host, "/api/push", dumps(payload)) - - h = hmac.new( - bytearray(self.secret, 'utf-8'), - bytearray(str_to_sign, 'utf-8'), - sha1, - ) - - params = NotifyBoxcar.urlencode({ - "publishkey": self.access, - "signature": h.hexdigest(), - }) - - notify_url = '%s?%s' % (self.notify_url, params) - self.logger.debug('Boxcar POST URL: %s (cert_verify=%r)' % ( - notify_url, self.verify_certificate, - )) - self.logger.debug('Boxcar 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, - ) - - # Boxcar returns 201 (Created) when successful - if r.status_code != requests.codes.created: - # We had a problem - status_str = \ - NotifyBoxcar.http_response_code_lookup(r.status_code) - - self.logger.warning( - 'Failed to send Boxcar 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 Boxcar notification.') - - except requests.RequestException as e: - self.logger.warning( - 'A Connection error occurred sending Boxcar ' - 'notification to %s.' % (host)) - - 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 = { - 'image': 'yes' if self.include_image else 'no', - } - - # Extend our parameters - params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) - - return '{schema}://{access}/{secret}/{targets}?{params}'.format( - schema=self.secure_protocol, - access=self.pprint(self.access, privacy, safe=''), - secret=self.pprint( - self.secret, privacy, mode=PrivacyMode.Secret, safe=''), - targets='/'.join([ - NotifyBoxcar.quote(x, safe='') for x in chain( - self._tags, self.device_tokens) if x != DEFAULT_TAG]), - params=NotifyBoxcar.urlencode(params), - ) - - @property - def url_identifier(self): - """ - Returns all of the identifiers that make this URL unique from - another simliar one. Targets or end points should never be identified - here. - """ - return (self.secure_protocol, self.access, self.secret) - - def __len__(self): - """ - Returns the number of targets associated with this notification - """ - targets = len(self._tags) + len(self.device_tokens) - # DEFAULT_TAG is set if no tokens/tags are otherwise set - return targets if targets > 0 else 1 - - @staticmethod - def parse_url(url): - """ - Parses the URL and returns it broken apart into a dictionary. - - """ - results = NotifyBase.parse_url(url, verify_host=False) - if not results: - # We're done early - return None - - # The first token is stored in the hostname - results['access'] = NotifyBoxcar.unquote(results['host']) - - # Get our entries; split_path() looks after unquoting content for us - # by default - entries = NotifyBoxcar.split_path(results['fullpath']) - - # Now fetch the remaining tokens - results['secret'] = entries.pop(0) if entries else None - - # Our recipients make up the remaining entries of our array - results['targets'] = entries - - # The 'to' makes it easier to use yaml configuration - if 'to' in results['qsd'] and len(results['qsd']['to']): - results['targets'] += \ - NotifyBoxcar.parse_list(results['qsd'].get('to')) - - # Access - if 'access' in results['qsd'] and results['qsd']['access']: - results['access'] = NotifyBoxcar.unquote( - results['qsd']['access'].strip()) - - # Secret - if 'secret' in results['qsd'] and results['qsd']['secret']: - results['secret'] = NotifyBoxcar.unquote( - results['qsd']['secret'].strip()) - - # Include images with our message - results['include_image'] = \ - parse_bool(results['qsd'].get('image', True)) - - return results diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index 87c31a3b..6c0ee36b 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -39,7 +39,7 @@ Apprise is a Python package for simplifying access to all of the different notification services that are out there. Apprise opens the door and makes it easy to access: -Africas Talking, Apprise API, APRS, AWS SES, AWS SNS, Bark, Boxcar, Burst SMS, +Africas Talking, Apprise API, APRS, AWS SES, AWS SNS, Bark, Burst SMS, BulkSMS, BulkVS, Chantify, ClickSend, DAPNET, DingTalk, Discord, E-Mail, Emby, FCM, Feishu, Flock, Free Mobile, Google Chat, Gotify, Growl, Guilded, Home Assistant, httpSMS, IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, Line, diff --git a/test/test_plugin_boxcar.py b/test/test_plugin_boxcar.py deleted file mode 100644 index c815b4b1..00000000 --- a/test/test_plugin_boxcar.py +++ /dev/null @@ -1,189 +0,0 @@ -# -*- coding: utf-8 -*- -# BSD 2-Clause License -# -# Apprise - Push Notification Library. -# Copyright (c) 2024, Chris Caron -# -# 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 pytest -from unittest import mock - -from apprise.plugins.boxcar import NotifyBoxcar -from helpers import AppriseURLTester -from apprise import NotifyType -import requests - -# Disable logging for a cleaner testing output -import logging -logging.disable(logging.CRITICAL) - -# Our Testing URLs -apprise_url_tests = ( - ('boxcar://', { - # invalid secret key - 'instance': TypeError, - }), - # A a bad url - ('boxcar://:@/', { - 'instance': TypeError, - }), - # No secret specified - ('boxcar://%s' % ('a' * 64), { - 'instance': TypeError, - }), - # No access specified (whitespace is trimmed) - ('boxcar://%%20/%s' % ('a' * 64), { - 'instance': TypeError, - }), - # No secret specified (whitespace is trimmed) - ('boxcar://%s/%%20' % ('a' * 64), { - 'instance': TypeError, - }), - # Provide both an access and a secret - ('boxcar://%s/%s' % ('a' * 64, 'b' * 64), { - 'instance': NotifyBoxcar, - 'requests_response_code': requests.codes.created, - # Our expected url(privacy=True) startswith() response: - 'privacy_url': 'boxcar://a...a/****/', - }), - # Test without image set - ('boxcar://%s/%s?image=True' % ('a' * 64, 'b' * 64), { - 'instance': NotifyBoxcar, - 'requests_response_code': requests.codes.created, - # don't include an image in Asset by default - 'include_image': False, - }), - ('boxcar://%s/%s?image=False' % ('a' * 64, 'b' * 64), { - 'instance': NotifyBoxcar, - 'requests_response_code': requests.codes.created, - }), - # our access, secret and device are all 64 characters - # which is what we're doing here - ('boxcar://%s/%s/@tag1/tag2///%s/?to=tag3' % ( - 'a' * 64, 'b' * 64, 'd' * 64), { - 'instance': NotifyBoxcar, - 'requests_response_code': requests.codes.created, - }), - ('boxcar://?access=%s&secret=%s&to=tag5' % ('d' * 64, 'b' * 64), { - # Test access and secret kwargs - 'privacy_url': 'boxcar://d...d/****/', - 'instance': NotifyBoxcar, - 'requests_response_code': requests.codes.created, - }), - # An invalid tag - ('boxcar://%s/%s/@%s' % ('a' * 64, 'b' * 64, 't' * 64), { - 'instance': NotifyBoxcar, - 'requests_response_code': requests.codes.created, - }), - ('boxcar://%s/%s/' % ('a' * 64, 'b' * 64), { - 'instance': NotifyBoxcar, - # force a failure - 'response': False, - 'requests_response_code': requests.codes.internal_server_error, - }), - ('boxcar://%s/%s/' % ('a' * 64, 'b' * 64), { - 'instance': NotifyBoxcar, - # throw a bizzare code forcing us to fail to look it up - 'response': False, - 'requests_response_code': 999, - }), - ('boxcar://%s/%s/' % ('a' * 64, 'b' * 64), { - 'instance': NotifyBoxcar, - # Throws a series of connection and transfer exceptions when this flag - # is set and tests that we gracfully handle them - 'test_requests_exceptions': True, - }), -) - - -def test_plugin_boxcar_urls(): - """ - NotifyBoxcar() Apprise URLs - - """ - - # Run our general tests - AppriseURLTester(tests=apprise_url_tests).run_all() - - -@mock.patch('requests.get') -@mock.patch('requests.post') -def test_plugin_boxcar_edge_cases(mock_post, mock_get): - """ - NotifyBoxcar() Edge Cases - - """ - - # Generate some generic message types - device = 'A' * 64 - tag = '@B' * 63 - - access = '-' * 64 - secret = '_' * 64 - - # Initializes the plugin with recipients set to None - NotifyBoxcar(access=access, secret=secret, targets=None) - - # Initializes the plugin with a valid access, but invalid access key - with pytest.raises(TypeError): - NotifyBoxcar(access=None, secret=secret, targets=None) - - # Initializes the plugin with a valid access, but invalid secret - with pytest.raises(TypeError): - NotifyBoxcar(access=access, secret=None, targets=None) - - # Initializes the plugin with recipients list - # the below also tests our the variation of recipient types - NotifyBoxcar( - access=access, secret=secret, targets=[device, tag]) - - mock_get.return_value = requests.Request() - mock_post.return_value = requests.Request() - mock_post.return_value.status_code = requests.codes.created - mock_get.return_value.status_code = requests.codes.created - - # Test notifications without a body or a title - p = NotifyBoxcar(access=access, secret=secret, targets=None) - - # Neither a title or body was specified - assert p.notify( - body=None, title=None, notify_type=NotifyType.INFO) is False - - # Acceptable when data is provided: - assert p.notify( - body="Test", title=None, notify_type=NotifyType.INFO) is True - - # Test comma, separate values - device = 'a' * 64 - p = NotifyBoxcar( - access=access, secret=secret, - targets=','.join([device, device, device])) - # unique entries are colapsed into 1 - assert len(p.device_tokens) == 1 - - p = NotifyBoxcar( - access=access, secret=secret, - targets=','.join(['a' * 64, 'b' * 64, 'c' * 64])) - # not unique, so we get the same data here - assert len(p.device_tokens) == 3