diff --git a/README.md b/README.md
index 555fa5fd..1a19a000 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,7 @@ The table below identifies the services this tool supports and some example serv
| Notification Service | Service ID | Default Port | Example Syntax |
| -------------------- | ---------- | ------------ | -------------- |
| [AWS SNS](https://github.com/caronc/apprise/wiki/Notify_sns) | sns:// | (TCP) 443 | sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo
sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo1/+PhoneNo2/+PhoneNoN
sns://AccessKeyID/AccessSecretKey/RegionName/Topic
sns://AccessKeyID/AccessSecretKey/RegionName/Topic1/Topic2/TopicN
+| [D7 Networks](https://github.com/caronc/apprise/wiki/Notify_d7networks) | d7sms:// | (TCP) 443 | d7sms://user:pass@PhoneNo
d7sms://user:pass@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN
| [Nexmo](https://github.com/caronc/apprise/wiki/Notify_nexmo) | nexmo:// | (TCP) 443 | nexmo://ApiKey:ApiSecret@FromPhoneNo
nexmo://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo
nexmo://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
| [Twilio](https://github.com/caronc/apprise/wiki/Notify_twilio) | twilio:// | (TCP) 443 | twilio://AccountSid:AuthToken@FromPhoneNo
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo
twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
diff --git a/apprise/plugins/NotifyD7Networks.py b/apprise/plugins/NotifyD7Networks.py
new file mode 100644
index 00000000..1b7fcb5e
--- /dev/null
+++ b/apprise/plugins/NotifyD7Networks.py
@@ -0,0 +1,474 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2019 Chris Caron
+# All rights reserved.
+#
+# This code is licensed under the MIT License.
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files(the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions :
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+# To use this service you will need a D7 Networks account from their website
+# at https://d7networks.com/
+#
+# After you've established your account you can get your api login credentials
+# (both user and password) from the API Details section from within your
+# account profile area: https://d7networks.com/accounts/profile/
+
+import re
+import six
+import requests
+import base64
+from json import dumps
+from json import loads
+
+from .NotifyBase import NotifyBase
+from ..common import NotifyType
+from ..utils import parse_list
+from ..utils import parse_bool
+from ..AppriseLocale import gettext_lazy as _
+
+# Extend HTTP Error Messages
+D7NETWORKS_HTTP_ERROR_MAP = {
+ 401: 'Invalid Argument(s) Specified.',
+ 403: 'Unauthorized - Authentication Failure.',
+ 412: 'A Routing Error Occured',
+ 500: 'A Serverside Error Occured Handling the Request.',
+}
+
+# Some Phone Number Detection
+IS_PHONE_NO = re.compile(r'^\+?(?P[0-9\s)(+-]+)\s*$')
+
+
+# Priorities
+class D7SMSPriority(object):
+ """
+ D7 Networks SMS Message Priority
+ """
+ LOW = 0
+ MODERATE = 1
+ NORMAL = 2
+ HIGH = 3
+
+
+D7NETWORK_SMS_PRIORITIES = (
+ D7SMSPriority.LOW,
+ D7SMSPriority.MODERATE,
+ D7SMSPriority.NORMAL,
+ D7SMSPriority.HIGH,
+)
+
+
+class NotifyD7Networks(NotifyBase):
+ """
+ A wrapper for D7 Networks Notifications
+ """
+
+ # The default descriptive name associated with the Notification
+ service_name = 'D7 Networks'
+
+ # The services URL
+ service_url = 'https://d7networks.com/'
+
+ # All pushover requests are secure
+ secure_protocol = 'd7sms'
+
+ # Allow 300 requests per minute.
+ # 60/300 = 0.2
+ request_rate_per_sec = 0.20
+
+ # A URL that takes you to the setup/help of the specific protocol
+ setup_url = 'https://github.com/caronc/apprise/wiki/Notify_twilio'
+
+ # D7 Networks batch notification URL
+ notify_batch_url = 'http://rest-api.d7networks.com/secure/sendbatch'
+
+ # D7 Networks single notification URL
+ notify_url = 'http://rest-api.d7networks.com/secure/send'
+
+ # 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}://{user}:{password}@{targets}',
+ )
+
+ # Define our template tokens
+ template_tokens = dict(NotifyBase.template_tokens, **{
+ 'user': {
+ 'name': _('Username'),
+ 'type': 'string',
+ 'required': True,
+ },
+ 'password': {
+ 'name': _('Password'),
+ 'type': 'string',
+ 'private': True,
+ 'required': True,
+ },
+ 'target_phone': {
+ 'name': _('Target Phone No'),
+ 'type': 'string',
+ 'prefix': '+',
+ 'regex': (r'[0-9\s)(+-]+', 'i'),
+ 'map_to': 'targets',
+ },
+ 'targets': {
+ 'name': _('Targets'),
+ 'type': 'list:string',
+ },
+ })
+
+ # Define our template arguments
+ template_args = dict(NotifyBase.template_args, **{
+ 'priority': {
+ 'name': _('Priority'),
+ 'type': 'choice:int',
+ 'min': D7SMSPriority.LOW,
+ 'max': D7SMSPriority.HIGH,
+ 'values': D7NETWORK_SMS_PRIORITIES,
+
+ # The website identifies that the default priority is low; so
+ # this plugin will honor that same default
+ 'default': D7SMSPriority.LOW,
+ },
+ 'batch': {
+ 'name': _('Batch Mode'),
+ 'type': 'bool',
+ 'default': False,
+ },
+ 'to': {
+ 'alias_of': 'targets',
+ },
+ 'source': {
+ # Originating address,In cases where the rewriting of the sender's
+ # address is supported or permitted by the SMS-C. This is used to
+ # transmit the message, this number is transmitted as the
+ # originating address and is completely optional.
+ 'name': _('Originating Address'),
+ 'type': 'string',
+ 'map_to': 'source',
+
+ },
+ 'from': {
+ 'alias_of': 'source',
+ },
+ })
+
+ def __init__(self, targets=None, priority=None, source=None, batch=False,
+ **kwargs):
+ """
+ Initialize D7 Networks Object
+ """
+ super(NotifyD7Networks, self).__init__(**kwargs)
+
+ # The Priority of the message
+ if priority not in D7NETWORK_SMS_PRIORITIES:
+ self.priority = self.template_args['priority']['default']
+
+ else:
+ self.priority = priority
+
+ # Prepare Batch Mode Flag
+ self.batch = batch
+
+ # Setup our source address (if defined)
+ self.source = None \
+ if not isinstance(source, six.string_types) else source.strip()
+
+ # Parse our targets
+ self.targets = list()
+
+ for target in parse_list(targets):
+ # Validate targets and drop bad ones:
+ result = IS_PHONE_NO.match(target)
+ if result:
+ # Further check our phone # for it's digit count
+ # if it's less than 10, then we can assume it's
+ # a poorly specified phone no and spit a warning
+ result = ''.join(re.findall(r'\d+', result.group('phone')))
+ if len(result) < 11 or len(result) > 14:
+ self.logger.warning(
+ 'Dropped invalid phone # '
+ '({}) specified.'.format(target),
+ )
+ continue
+
+ # store valid phone number
+ self.targets.append(result)
+ continue
+
+ self.logger.warning(
+ 'Dropped invalid phone # ({}) specified.'.format(target))
+
+ if len(self.targets) == 0:
+ msg = 'There are no valid targets identified to notify.'
+ self.logger.warning(msg)
+ raise TypeError(msg)
+
+ def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
+ """
+ Depending on whether we are set to batch mode or single mode this
+ redirects to the appropriate handling
+ """
+
+ # error tracking (used for function return)
+ has_error = False
+
+ auth = '{user}:{password}'.format(
+ user=self.user, password=self.password)
+ if six.PY3:
+ # Python 3's versio of b64encode() expects a byte array and not
+ # a string. To accomodate this, we encode the content here
+ auth = auth.encode('utf-8')
+
+ # Prepare our headers
+ headers = {
+ 'User-Agent': self.app_id,
+ 'Accept': 'application/json',
+ 'Authorization': 'Basic {}'.format(base64.b64encode(auth))
+ }
+
+ # Our URL varies depending if we're doing a batch mode or not
+ url = self.notify_batch_url if self.batch else self.notify_url
+
+ # use the list directly
+ targets = list(self.targets)
+
+ while len(targets):
+
+ if self.batch:
+ # Prepare our payload
+ payload = {
+ 'globals': {
+ 'priority': self.priority,
+ 'from': self.source if self.source else self.app_id,
+ },
+ 'messages': [{
+ 'to': self.targets,
+ 'content': body,
+ }],
+ }
+
+ # Reset our targets so we don't keep going. This is required
+ # because we're in batch mode; we only need to loop once.
+ targets = []
+
+ else:
+ # We're not in a batch mode; so get our next target
+ # Get our target(s) to notify
+ target = targets.pop(0)
+
+ # Prepare our payload
+ payload = {
+ 'priority': self.priority,
+ 'content': body,
+ 'to': target,
+ 'from': self.source if self.source else self.app_id,
+ }
+
+ # Some Debug Logging
+ self.logger.debug(
+ 'D7 Networks POST URL: {} (cert_verify={})'.format(
+ url, self.verify_certificate))
+ self.logger.debug('D7 Networks Payload: {}' .format(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,
+ )
+
+ if r.status_code not in (
+ requests.codes.created, requests.codes.ok):
+ # We had a problem
+ status_str = \
+ NotifyBase.http_response_code_lookup(
+ r.status_code, D7NETWORKS_HTTP_ERROR_MAP)
+
+ try:
+ # Update our status response if we can
+ json_response = loads(r.content)
+ status_str = json_response.get('message', status_str)
+
+ except (AttributeError, ValueError):
+ # could not parse JSON response... just use the status
+ # we already have.
+
+ # AttributeError means r.content was None
+ pass
+
+ self.logger.warning(
+ 'Failed to send D7 Networks SMS notification to {}: '
+ '{}{}error={}.'.format(
+ ', '.join(target) if self.batch else target,
+ 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:
+
+ if self.batch:
+ count = len(self.targets)
+ try:
+ # Get our message delivery count if we can
+ json_response = loads(r.content)
+ count = int(json_response.get(
+ 'data', {}).get('messageCount', -1))
+
+ except (AttributeError, ValueError, TypeError):
+ # could not parse JSON response... just assume
+ # that our delivery is okay for now
+ pass
+
+ if count != len(self.targets):
+ has_error = True
+
+ self.logger.info(
+ 'Sent D7 Networks batch SMS notification to '
+ '{} of {} target(s).'.format(
+ count, len(self.targets)))
+
+ else:
+ self.logger.info(
+ 'Sent D7 Networks SMS notification to {}.'.format(
+ target))
+
+ self.logger.debug(
+ 'Response Details:\r\n{}'.format(r.content))
+
+ except requests.RequestException as e:
+ self.logger.warning(
+ 'A Connection error occured sending D7 Networks:%s ' % (
+ ', '.join(self.targets)) + 'notification.'
+ )
+ self.logger.debug('Socket Exception: %s' % str(e))
+ # Mark our failure
+ has_error = True
+ continue
+
+ return not has_error
+
+ def url(self):
+ """
+ Returns the URL built dynamically based on specified arguments.
+ """
+
+ # Define any arguments set
+ args = {
+ 'format': self.notify_format,
+ 'overflow': self.overflow_mode,
+ 'verify': 'yes' if self.verify_certificate else 'no',
+ 'batch': 'yes' if self.batch else 'no',
+ }
+
+ if self.priority != self.template_args['priority']['default']:
+ args['priority'] = str(self.priority)
+
+ if self.source:
+ args['from'] = self.source
+
+ return '{schema}://{user}:{password}@{targets}/?{args}'.format(
+ schema=self.secure_protocol,
+ user=NotifyD7Networks.quote(self.user, safe=''),
+ password=NotifyD7Networks.quote(self.password, safe=''),
+ targets='/'.join(
+ [NotifyD7Networks.quote(x, safe='') for x in self.targets]),
+ args=NotifyD7Networks.urlencode(args))
+
+ @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 load the results
+ return results
+
+ # Initialize our targets
+ results['targets'] = list()
+
+ # The store our first target stored in the hostname
+ results['targets'].append(NotifyD7Networks.unquote(results['host']))
+
+ # Get our entries; split_path() looks after unquoting content for us
+ # by default
+ results['targets'].extend(
+ NotifyD7Networks.split_path(results['fullpath']))
+
+ # Set our priority
+ if 'priority' in results['qsd'] and len(results['qsd']['priority']):
+ _map = {
+ 'l': D7SMSPriority.LOW,
+ '0': D7SMSPriority.LOW,
+ 'm': D7SMSPriority.MODERATE,
+ '1': D7SMSPriority.MODERATE,
+ 'n': D7SMSPriority.NORMAL,
+ '2': D7SMSPriority.NORMAL,
+ 'h': D7SMSPriority.HIGH,
+ '3': D7SMSPriority.HIGH,
+ }
+ try:
+ results['priority'] = \
+ _map[results['qsd']['priority'][0].lower()]
+
+ except KeyError:
+ # No priority was set
+ pass
+
+ # 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'] = \
+ NotifyD7Networks.unquote(results['qsd']['from'])
+ if 'source' in results['qsd'] and len(results['qsd']['source']):
+ results['source'] = \
+ NotifyD7Networks.unquote(results['qsd']['source'])
+
+ # Get Batch Mode Flag
+ results['batch'] = \
+ parse_bool(results['qsd'].get('batch', False))
+
+ # 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'] += \
+ NotifyD7Networks.parse_list(results['qsd']['to'])
+
+ return results
diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py
index e7cc5699..9789f48f 100644
--- a/test/test_rest_plugins.py
+++ b/test/test_rest_plugins.py
@@ -127,6 +127,78 @@ TEST_URLS = (
'test_requests_exceptions': True,
}),
+ ##################################
+ # NotifyD7Networks
+ ##################################
+ ('d7sms://', {
+ # No token specified
+ 'instance': None,
+ }),
+ ('d7sms://:@/', {
+ # invalid user/pass
+ 'instance': TypeError,
+ }),
+ ('d7sms://user:pass@{}/{}/{}'.format('1' * 10, '2' * 15, 'a' * 13), {
+ # invalid target numbers
+ 'instance': TypeError,
+ }),
+ ('d7sms://user:pass@{}?batch=yes'.format('3' * 14), {
+ # valid number
+ 'instance': plugins.NotifyD7Networks,
+ }),
+ ('d7sms://user:pass@{}?batch=yes'.format('7' * 14), {
+ # valid number
+ 'instance': plugins.NotifyD7Networks,
+ # Test what happens if a batch send fails to return a messageCount
+ 'requests_response_text': {
+ 'data': {
+ 'messageCount': 0,
+ },
+ },
+ # Expected notify() response
+ 'notify_response': False,
+ }),
+ ('d7sms://user:pass@{}?batch=yes&to={}'.format('3' * 14, '6' * 14), {
+ # valid number
+ 'instance': plugins.NotifyD7Networks,
+ }),
+ ('d7sms://user:pass@{}?batch=yes&from=apprise'.format('3' * 14), {
+ # valid number, utilizing the optional from= variable
+ 'instance': plugins.NotifyD7Networks,
+ }),
+ ('d7sms://user:pass@{}?batch=yes&source=apprise'.format('3' * 14), {
+ # valid number, utilizing the optional source= variable (same as from)
+ 'instance': plugins.NotifyD7Networks,
+ }),
+ ('d7sms://user:pass@{}?priority=invalid'.format('3' * 14), {
+ # valid number; invalid priority
+ 'instance': plugins.NotifyD7Networks,
+ }),
+ ('d7sms://user:pass@{}?priority=3'.format('3' * 14), {
+ # valid number; adjusted priority
+ 'instance': plugins.NotifyD7Networks,
+ }),
+ ('d7sms://user:pass@{}?priority=high'.format('3' * 14), {
+ # valid number; adjusted priority (string supported)
+ 'instance': plugins.NotifyD7Networks,
+ }),
+ ('d7sms://user:pass@{}?batch=no'.format('3' * 14), {
+ # valid number - no batch
+ 'instance': plugins.NotifyD7Networks,
+ }),
+ ('d7sms://user:pass@{}'.format('3' * 14), {
+ 'instance': plugins.NotifyD7Networks,
+ # throw a bizzare code forcing us to fail to look it up
+ 'response': False,
+ 'requests_response_code': 999,
+ }),
+ ('d7sms://user:pass@{}'.format('3' * 14), {
+ 'instance': plugins.NotifyD7Networks,
+ # Throws a series of connection and transfer exceptions when this flag
+ # is set and tests that we gracfully handle them
+ 'test_requests_exceptions': True,
+ }),
+
##################################
# NotifyDiscord
##################################
@@ -2446,6 +2518,9 @@ def test_rest_plugins(mock_post, mock_get):
# Our expected Query response (True, False, or exception type)
response = meta.get('response', True)
+ # Our expected Notify response (True or False)
+ notify_response = meta.get('notify_response', response)
+
# Allow us to force the server response code to be something other then
# the defaults
requests_response_code = meta.get(
@@ -2558,22 +2633,22 @@ def test_rest_plugins(mock_post, mock_get):
# check that we're as expected
assert obj.notify(
body=body, title=title,
- notify_type=notify_type) == response
+ notify_type=notify_type) == notify_response
# check that this doesn't change using different overflow
# methods
assert obj.notify(
body=body, title=title,
notify_type=notify_type,
- overflow=OverflowMode.UPSTREAM) == response
+ overflow=OverflowMode.UPSTREAM) == notify_response
assert obj.notify(
body=body, title=title,
notify_type=notify_type,
- overflow=OverflowMode.TRUNCATE) == response
+ overflow=OverflowMode.TRUNCATE) == notify_response
assert obj.notify(
body=body, title=title,
notify_type=notify_type,
- overflow=OverflowMode.SPLIT) == response
+ overflow=OverflowMode.SPLIT) == notify_response
else:
# Disable throttling
@@ -2616,8 +2691,8 @@ def test_rest_plugins(mock_post, mock_get):
try:
if test_requests_exceptions is False:
# check that we're as expected
- assert obj.notify(
- body='body', notify_type=notify_type) == response
+ assert obj.notify(body='body', notify_type=notify_type) \
+ == notify_response
else:
for _exception in REQUEST_EXCEPTIONS: