From 603e00bbce4bd78409d5724122064736f4fae9c0 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Thu, 6 Jun 2019 22:59:05 -0400 Subject: [PATCH] Added Nexmo SMS Notification Support (#123) --- README.md | 9 +- apprise/plugins/NotifyNexmo.py | 416 +++++++++++++++++++++++++++ packaging/redhat/python-apprise.spec | 2 +- setup.py | 2 +- test/test_api.py | 2 - test/test_rest_plugins.py | 132 +++++++++ 6 files changed, 557 insertions(+), 6 deletions(-) create mode 100644 apprise/plugins/NotifyNexmo.py diff --git a/README.md b/README.md index 0eca4449..2e6d9d7d 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ 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 | [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 | [Discord](https://github.com/caronc/apprise/wiki/Notify_discord) | discord:// | (TCP) 443 | discord://webhook_id/webhook_token
discord://avatar@webhook_id/webhook_token | [Dbus](https://github.com/caronc/apprise/wiki/Notify_dbus) | dbus://
qt://
glib://
kde:// | n/a | dbus://
qt://
glib://
kde:// @@ -59,7 +58,6 @@ The table below identifies the services this tool supports and some example serv | [Ryver](https://github.com/caronc/apprise/wiki/Notify_ryver) | ryver:// | (TCP) 443 | ryver://Organization/Token
ryver://botname@Organization/Token | [Slack](https://github.com/caronc/apprise/wiki/Notify_slack) | slack:// | (TCP) 443 | slack://TokenA/TokenB/TokenC/
slack://TokenA/TokenB/TokenC/Channel
slack://botname@TokenA/TokenB/TokenC/Channel
slack://user@TokenA/TokenB/TokenC/Channel1/Channel2/ChannelN | [Telegram](https://github.com/caronc/apprise/wiki/Notify_telegram) | tgram:// | (TCP) 443 | tgram://bottoken/ChatID
tgram://bottoken/ChatID1/ChatID2/ChatIDN -| [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/ | [Twitter](https://github.com/caronc/apprise/wiki/Notify_twitter) | tweet:// | (TCP) 443 | tweet://user@CKey/CSecret/AKey/ASecret | [XBMC](https://github.com/caronc/apprise/wiki/Notify_xbmc) | xbmc:// or xbmcs:// | (TCP) 8080 or 443 | xbmc://hostname
xbmc://user@hostname
xbmc://user:password@hostname:port | [XMPP](https://github.com/caronc/apprise/wiki/Notify_xmpp) | xmpp:// or xmpps:// | (TCP) 5222 or 5223 | xmpp://password@hostname
xmpp://user:password@hostname
xmpps://user:password@hostname:port?jid=user@hostname/resource
xmpps://password@hostname/target@myhost, target2@myhost/resource @@ -67,6 +65,13 @@ The table below identifies the services this tool supports and some example serv | [Webex Teams (Cisco)](https://github.com/caronc/apprise/wiki/Notify_wxteams) | wxteams:// | (TCP) 443 | wxteams://Token +### SMS Notification Support +| 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 +| [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/ + ### Email Support | Service ID | Default Port | Example Syntax | | ---------- | ------------ | -------------- | diff --git a/apprise/plugins/NotifyNexmo.py b/apprise/plugins/NotifyNexmo.py new file mode 100644 index 00000000..916bdf8c --- /dev/null +++ b/apprise/plugins/NotifyNexmo.py @@ -0,0 +1,416 @@ +# -*- 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. + +# Sign-up with https://dashboard.nexmo.com/ +# +# Get your (api) key and secret here: +# - https://dashboard.nexmo.com/getting-started-guide +# + +import re +import requests + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import parse_list +from ..AppriseLocale import gettext_lazy as _ + +# Token required as part of the API request +VALIDATE_APIKEY = re.compile(r'^[a-z0-9]{8}$', re.I) +VALIDATE_SECRET = re.compile(r'^[a-z0-9]{16}$', re.I) + +# Some Phone Number Detection +IS_PHONE_NO = re.compile(r'^\+?(?P[0-9\s)(+-]+)\s*$') + + +class NotifyNexmo(NotifyBase): + """ + A wrapper for Nexmo Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Nexmo' + + # The services URL + service_url = 'https://dashboard.nexmo.com/' + + # The default protocol + secure_protocol = 'nexmo' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_nexmo' + + # Nexmo uses the http protocol with JSON requests + notify_url = 'https://rest.nexmo.com/sms/json' + + # The maximum length of the body + body_maxlen = 140 + + # 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 + + # Default Time To Live + # By default Nexmo attempt delivery for 72 hours, however the maximum + # effective value depends on the operator and is typically 24 - 48 hours. + # We recommend this value should be kept at its default or at least 30 + # minutes. + default_ttl = 900000 + ttl_max = 604800000 + ttl_min = 20000 + + # Define object templates + templates = ( + '{schema}://{apikey}:{secret}@{from_phone}', + '{schema}://{apikey}:{secret}@{from_phone}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'required': True, + 'regex': (r'AC[a-z0-9]{8}', 'i'), + }, + 'secret': { + 'name': _('API Secret'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'[a-z0-9]{16}', 'i'), + }, + 'from_phone': { + 'name': _('From Phone No'), + 'type': 'string', + 'required': True, + 'regex': (r'\+?[0-9\s)(+-]+', 'i'), + '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', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'from': { + 'alias_of': 'from_phone', + }, + 'key': { + 'alias_of': 'apikey', + }, + 'secret': { + 'alias_of': 'secret', + }, + 'ttl': { + 'name': _('ttl'), + 'type': 'int', + 'default': 900000, + 'min': 20000, + 'max': 604800000, + }, + }) + + def __init__(self, apikey, secret, source, targets=None, ttl=None, + **kwargs): + """ + Initialize Nexmo Object + """ + super(NotifyNexmo, self).__init__(**kwargs) + + try: + # The Account SID associated with the account + self.apikey = apikey.strip() + + except AttributeError: + # Token was None + msg = 'No Nexmo APIKey was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + if not VALIDATE_APIKEY.match(self.apikey): + msg = 'The Nexmo API Key specified ({}) is invalid.'\ + .format(self.apikey) + self.logger.warning(msg) + raise TypeError(msg) + + try: + # The Account SID associated with the account + self.secret = secret.strip() + + except AttributeError: + # Token was None + msg = 'No Nexmo API Secret was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + if not VALIDATE_SECRET.match(self.secret): + msg = 'The Nexmo API Secret specified ({}) is invalid.'\ + .format(self.secret) + self.logger.warning(msg) + raise TypeError(msg) + + # Set our Time to Live Flag + self.ttl = self.default_ttl + try: + self.ttl = int(ttl) + + except (ValueError, TypeError): + # Do nothing + pass + + if self.ttl < self.ttl_min or self.ttl > self.ttl_max: + msg = 'The Nexmo TTL specified ({}) is out of range.'\ + .format(self.ttl) + self.logger.warning(msg) + raise TypeError(msg) + + # The Source Phone # + self.source = source + + if not IS_PHONE_NO.match(self.source): + msg = 'The Account (From) Phone # specified ' \ + '({}) is invalid.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # Tidy source + self.source = re.sub(r'[^\d]+', '', self.source) + if len(self.source) < 11 or len(self.source) > 14: + msg = 'The Account (From) Phone # specified ' \ + '({}) contains an invalid digit count.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # 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 + 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), + ) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Nexmo Notification + """ + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded', + } + + # Prepare our payload + payload = { + 'api_key': self.apikey, + 'api_secret': self.secret, + 'ttl': self.ttl, + 'from': self.source, + 'text': body, + + # The to gets populated in the loop below + 'to': None, + } + + # Create a copy of the targets list + targets = list(self.targets) + + if len(targets) == 0: + # No sources specified, use our own phone no + targets.append(self.source) + + while len(targets): + # Get our target to notify + target = targets.pop(0) + + # Prepare our user + payload['to'] = target + + # Some Debug Logging + self.logger.debug('Nexmo POST URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('Nexmo 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, + verify=self.verify_certificate, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyNexmo.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Nexmo notification to {}: ' + '{}{}error={}.'.format( + 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: + self.logger.info('Sent Nexmo notification to %s.' % target) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending Nexmo:%s ' + 'notification.' % target + ) + 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', + 'ttl': str(self.ttl), + } + + return '{schema}://{key}:{secret}@{source}/{targets}/?{args}'.format( + schema=self.secure_protocol, + key=self.apikey, + secret=self.secret, + source=NotifyNexmo.quote(self.source, safe=''), + targets='/'.join( + [NotifyNexmo.quote(x, safe='') for x in self.targets]), + args=NotifyNexmo.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 + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = NotifyNexmo.split_path(results['fullpath']) + + # The hostname is our source number + results['source'] = NotifyNexmo.unquote(results['host']) + + # Get our account_side and auth_token from the user/pass config + results['apikey'] = NotifyNexmo.unquote(results['user']) + results['secret'] = NotifyNexmo.unquote(results['password']) + + # API Key + if 'key' in results['qsd'] and len(results['qsd']['key']): + # Extract the API Key from an argument + results['apikey'] = \ + NotifyNexmo.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'] = \ + NotifyNexmo.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'] = \ + NotifyNexmo.unquote(results['qsd']['from']) + if 'source' in results['qsd'] and len(results['qsd']['source']): + results['source'] = \ + NotifyNexmo.unquote(results['qsd']['source']) + + # Support the 'ttl' variable + if 'ttl' in results['qsd'] and len(results['qsd']['ttl']): + results['ttl'] = \ + NotifyNexmo.unquote(results['qsd']['ttl']) + + # 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'] += \ + NotifyNexmo.parse_list(results['qsd']['to']) + + return results diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index b2c592e2..add06182 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -49,7 +49,7 @@ it easy to access: Boxcar, Discord, E-Mail, Emby, Faast, Flock, Gitter, Gotify, Growl, IFTTT, Join, KODI, Mailgun, MatterMost, Matrix, Microsoft Windows Notifications, -Microsoft Teams, Notify MyAndroid, Prowl, Pushalot, PushBullet, Pushjet, +Microsoft Teams, Nexmo, Notify MyAndroid, Prowl, Pushalot, PushBullet, Pushjet, Pushover, Rocket.Chat, Slack, Super Toasty, Stride, Telegram, Twilio, Twitter, XBMC, XMPP, Webex Teams} diff --git a/setup.py b/setup.py index ecb79099..54b56426 100755 --- a/setup.py +++ b/setup.py @@ -71,7 +71,7 @@ setup( url='https://github.com/caronc/apprise', keywords='Push Notifications Alerts Email AWS SNS Boxcar Discord Dbus ' 'Emby Faast Flock Gitter Gnome Gotify Growl IFTTT Join KODI Mailgun ' - 'Matrix Mattermost Prowl PushBullet Pushjet Pushed Pushover ' + 'Matrix Mattermost Nexmo Prowl PushBullet Pushjet Pushed Pushover ' 'Rocket.Chat Ryver Slack Stride Telegram Twilio Twitter XBMC ' 'Microsoft MSTeams Windows Webex CLI API', author='Chris Caron', diff --git a/test/test_api.py b/test/test_api.py index 93ff270b..c82b5628 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -1071,8 +1071,6 @@ def test_apprise_details_plugin_verification(): assert isinstance(arg['alias_of'], six.string_types) # Track our alias_of object map_to_aliases.add(arg['alias_of']) - # We should never map to ourselves - assert arg['alias_of'] != key # 2 entries (name, and alias_of only!) assert len(entry['details'][section][key]) == 1 diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index ff6380a9..fc674b36 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -2056,6 +2056,79 @@ TEST_URLS = ( 'test_requests_exceptions': True, }), + ################################## + # NotifyNexmo + ################################## + ('nexmo://', { + # No secret and or key specified + 'instance': None, + }), + ('nexmo://:@/', { + # invalid Auth key + 'instance': TypeError, + }), + ('nexmo://{}@12345678'.format('a' * 8), { + # Just a key provided + 'instance': TypeError, + }), + ('nexmo://{}:{}@_'.format('a' * 8, 'b' * 16), { + # key and secret provided but invalid from + 'instance': TypeError, + }), + ('nexmo://{}:{}@{}'.format('a' * 23, 'b' * 16, '1' * 11), { + # key invalid and secret + 'instance': TypeError, + }), + ('nexmo://{}:{}@{}'.format('a' * 8, 'b' * 2, '2' * 11), { + # key and invalid secret + 'instance': TypeError, + }), + ('nexmo://{}:{}@{}'.format('a' * 8, 'b' * 16, '3' * 9), { + # key and secret provided and from but invalid from no + 'instance': TypeError, + }), + ('nexmo://{}:{}@{}/?ttl=0'.format('a' * 8, 'b' * 16, '3' * 11), { + # Invalid ttl defined + 'instance': TypeError, + }), + ('nexmo://{}:{}@{}/123/{}/abcd/'.format( + 'a' * 8, 'b' * 16, '3' * 11, '9' * 15), { + # valid everything but target numbers + 'instance': plugins.NotifyNexmo, + }), + ('nexmo://{}:{}@{}'.format('a' * 8, 'b' * 16, '5' * 11), { + # using phone no with no target - we text ourselves in + # this case + 'instance': plugins.NotifyNexmo, + }), + ('nexmo://_?key={}&secret={}&from={}'.format( + 'a' * 8, 'b' * 16, '5' * 11), { + # use get args to acomplish the same thing + 'instance': plugins.NotifyNexmo, + }), + ('nexmo://_?key={}&secret={}&source={}'.format( + 'a' * 8, 'b' * 16, '5' * 11), { + # use get args to acomplish the same thing (use source instead of from) + 'instance': plugins.NotifyNexmo, + }), + ('nexmo://_?key={}&secret={}&from={}&to={}'.format( + 'a' * 8, 'b' * 16, '5' * 11, '7' * 13), { + # use to= + 'instance': plugins.NotifyNexmo, + }), + ('nexmo://{}:{}@{}'.format('a' * 8, 'b' * 16, '6' * 11), { + 'instance': plugins.NotifyNexmo, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('nexmo://{}:{}@{}'.format('a' * 8, 'b' * 16, '6' * 11), { + 'instance': plugins.NotifyNexmo, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), + ################################## # NotifyWebexTeams ################################## @@ -2906,6 +2979,65 @@ def test_notify_twilio_plugin(mock_post): assert obj.notify('title', 'body', 'info') is False +@mock.patch('requests.post') +def test_notify_nexmo_plugin(mock_post): + """ + API: NotifyNexmo() Extra Checks + + """ + # Disable Throttling to speed testing + plugins.NotifyBase.request_rate_per_sec = 0 + + # Prepare our response + response = requests.Request() + response.status_code = requests.codes.ok + + # Prepare Mock + mock_post.return_value = response + + # Initialize some generic (but valid) tokens + apikey = '{}'.format('b' * 8) + secret = '{}'.format('b' * 16) + source = '+1 (555) 123-3456' + + try: + plugins.NotifyNexmo( + apikey=None, secret=secret, source=source) + # No apikey specified + assert False + + except TypeError: + # Exception should be thrown about the fact apikey was not + # specified + assert True + + try: + plugins.NotifyNexmo( + apikey=apikey, secret=None, source=source) + # No secret specified + assert False + + except TypeError: + # Exception should be thrown about the fact apikey was not + # specified + assert True + + # a error response + response.status_code = 400 + response.content = dumps({ + 'code': 21211, + 'message': "The 'To' number +1234567 is not a valid phone number.", + }) + mock_post.return_value = response + + # Initialize our object + obj = plugins.NotifyNexmo( + apikey=apikey, secret=secret, source=source) + + # We will fail with the above error code + assert obj.notify('title', 'body', 'info') is False + + @mock.patch('apprise.plugins.NotifyEmby.login') @mock.patch('requests.get') @mock.patch('requests.post')