diff --git a/KEYWORDS b/KEYWORDS index 822a0c07..75eb2ff4 100644 --- a/KEYWORDS +++ b/KEYWORDS @@ -66,6 +66,7 @@ Signal SimplePush Sinch Slack +SMSEagle SMTP2Go SNS SparkPost diff --git a/README.md b/README.md index 13c7066b..7e1b3503 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ The table below identifies the services this tool supports and some example serv | [MSG91](https://github.com/caronc/apprise/wiki/Notify_msg91) | msg91:// | (TCP) 443 | msg91://AuthKey/ToPhoneNo
msg91://SenderID@AuthKey/ToPhoneNo
msg91://AuthKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Signal API](https://github.com/caronc/apprise/wiki/Notify_signal) | signal:// or signals:// | (TCP) 80 or 443 | signal://hostname:port/FromPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Sinch](https://github.com/caronc/apprise/wiki/Notify_sinch) | sinch:// | (TCP) 443 | sinch://ServicePlanId:ApiToken@FromPhoneNo
sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo
sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo
sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ +| [SMSEagle](https://github.com/caronc/apprise/wiki/Notify_smseagle) | smseagle:// or smseagles:// | (TCP) 80 or 443 | smseagles://hostname:port/ToPhoneNo
smseagles://hostname:port/@ToContact
smseagles://hostname:port/#ToGroup
smseagles://hostname:port/ToPhoneNo1/#ToGroup/@ToContact/ | [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@FromPhoneNo/ToPhoneNo?apikey=Key
twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo
twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Vonage](https://github.com/caronc/apprise/wiki/Notify_nexmo) (formerly Nexmo) | nexmo:// | (TCP) 443 | nexmo://ApiKey:ApiSecret@FromPhoneNo
nexmo://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo
nexmo://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ diff --git a/apprise/plugins/NotifySMSEagle.py b/apprise/plugins/NotifySMSEagle.py new file mode 100644 index 00000000..b8840a8f --- /dev/null +++ b/apprise/plugins/NotifySMSEagle.py @@ -0,0 +1,638 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2022 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. + +import re +import requests +from json import dumps, loads +import base64 +from itertools import chain + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import validate_regex +from ..utils import is_phone_no +from ..utils import parse_phone_no +from ..utils import parse_bool +from ..URLBase import PrivacyMode +from ..AppriseLocale import gettext_lazy as _ + + +GROUP_REGEX = re.compile( + r'^\s*(\#|\%35)(?P[a-z0-9_-]+)', re.I) + +CONTACT_REGEX = re.compile( + r'^\s*(\@|\%40)?(?P[a-z0-9_-]+)', re.I) + + +# Priorities +class SMSEaglePriority(object): + NORMAL = 0 + HIGH = 1 + + +SMSEAGLE_PRIORITIES = ( + SMSEaglePriority.NORMAL, + SMSEaglePriority.HIGH, +) + +SMSEAGLE_PRIORITY_MAP = { + # short for 'normal' + 'normal': SMSEaglePriority.NORMAL, + # short for 'high' + '+': SMSEaglePriority.HIGH, + 'high': SMSEaglePriority.HIGH, +} + + +class NotifySMSEagle(NotifyBase): + """ + A wrapper for SMSEagle Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'SMS Eagle' + + # The services URL + service_url = 'https://smseagle.eu' + + # The default protocol + protocol = 'smseagle' + + # The default protocol + secure_protocol = 'smseagles' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_smseagle' + + # The path we send our notification to + notify_path = '/jsonrpc/sms' + + # The maxumum length of the text message + body_maxlen = 160 + + # The maximum targets to include when doing batch transfers + default_batch_size = 10 + + # We don't support titles for SMSEagle notifications + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{token}@{host}/{targets}', + '{schema}://{token}@{host}:{port}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'token': { + 'name': _('Access Token'), + 'type': 'string', + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'target_group': { + 'name': _('Target Group ID'), + 'type': 'string', + 'prefix': '#', + 'regex': (r'^[a-z0-9_-]+$', 'i'), + 'map_to': 'targets', + }, + 'target_contact': { + 'name': _('Target Contact'), + 'type': 'string', + 'prefix': '@', + 'regex': (r'^[a-z0-9_-]+$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + } + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'token': { + 'alias_of': 'token', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + 'status': { + 'name': _('Show Status'), + 'type': 'bool', + 'default': False, + }, + 'test': { + 'name': _('Test Only'), + 'type': 'bool', + 'default': False, + }, + 'flash': { + 'name': _('Flash'), + 'type': 'bool', + 'default': False, + }, + 'priority': { + 'name': _('Priority'), + 'type': 'choice:int', + 'values': SMSEAGLE_PRIORITIES, + 'default': SMSEaglePriority.NORMAL, + }, + }) + + def __init__(self, token=None, targets=None, priority=None, batch=False, + status=False, flash=False, test=False, **kwargs): + """ + Initialize SMSEagle Object + """ + super(NotifySMSEagle, self).__init__(**kwargs) + + # Prepare Flash Mode Flag + self.flash = flash + + # Prepare Test Mode Flag + self.test = test + + # Prepare Batch Mode Flag + self.batch = batch + + # Set Status type + self.status = status + + # Parse our targets + self.target_phones = list() + self.target_groups = list() + self.target_contacts = list() + + # Used for URL generation afterwards only + self.invalid_targets = list() + + # We always use a token if provided + self.token = validate_regex(self.user if not token else token) + if not self.token: + msg = \ + 'An invalid SMSEagle Access Token ({}) was specified.'.format( + self.user if not token else token) + self.logger.warning(msg) + raise TypeError(msg) + + # + # Priority + # + try: + # Acquire our priority if we can: + # - We accept both the integer form as well as a string + # representation + self.priority = int(priority) + + except TypeError: + # NoneType means use Default; this is an okay exception + self.priority = self.template_args['priority']['default'] + + except ValueError: + # Input is a string; attempt to get the lookup from our + # priority mapping + priority = priority.lower().strip() + + # This little bit of black magic allows us to match against + # low, lo, l (for low); + # normal, norma, norm, nor, no, n (for normal) + # ... etc + result = next((key for key in SMSEAGLE_PRIORITY_MAP.keys() + if key.startswith(priority)), None) \ + if priority else None + + # Now test to see if we got a match + if not result: + msg = 'An invalid SMSEagle priority ' \ + '({}) was specified.'.format(priority) + self.logger.warning(msg) + raise TypeError(msg) + + # store our successfully looked up priority + self.priority = SMSEAGLE_PRIORITY_MAP[result] + + if self.priority is not None and \ + self.priority not in SMSEAGLE_PRIORITY_MAP.values(): + msg = 'An invalid SMSEagle priority ' \ + '({}) was specified.'.format(priority) + self.logger.warning(msg) + raise TypeError(msg) + + # Validate our targerts + for target in parse_phone_no(targets): + # Validate targets and drop bad ones: + # Allow 9 digit numbers (without country code) + result = is_phone_no(target, min_len=9) + if result: + # store valid phone number + self.target_phones.append( + '{}{}'.format( + '' if target[0] != '+' else '+', result['full'])) + continue + + result = GROUP_REGEX.match(target) + if result: + # Just store group information + self.target_groups.append(result.group('group')) + continue + + result = CONTACT_REGEX.match(target) + if result: + # Just store contact information + self.target_contacts.append(result.group('contact')) + continue + + self.logger.warning( + 'Dropped invalid phone/group/contact ' + '({}) specified.'.format(target), + ) + self.invalid_targets.append(target) + continue + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform SMSEagle Notification + """ + + if not self.target_groups and not self.target_phones \ + and not self.target_contacts: + # There were no services to notify + self.logger.warning( + 'There were no SMSEagle targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + attachments = [] + if attach: + for attachment in attach: + # Perform some simple error checking + if not attachment: + # We could not access the attachment + self.logger.error( + 'Could not access attachment {}.'.format( + attachment.url(privacy=True))) + return False + + if not re.match(r'^image/.*', attachment.mimetype, re.I): + # Only support images at this time + self.logger.warning( + 'Ignoring unsupported SMSEagle attachment {}.'.format( + attachment.url(privacy=True))) + continue + + try: + with open(attachment.path, 'rb') as f: + # Prepare our Attachment in Base64 + attachments.append({ + 'content_type': attachment.mimetype, + 'content': base64.b64encode( + f.read()).decode('utf-8'), + }) + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading {}.'.format( + attachment.name if attachment else 'attachment')) + self.logger.debug('I/O Exception: %s' % str(e)) + return False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + # Prepare our payload + params_template = { + # Our Access Token + 'access_token': self.token, + + # The message to send (populated below) + "message": None, + + # 0 = normal priority, 1 = high priority + "highpriority": self.priority, + + # Support unicode characters + "unicode": 1, + + # sms or mms (if attachment) + "message_type": 'sms', + + # Response Types: + # simple: format response as simple object with one result field + # extended: format response as extended JSON object + "responsetype": 'extended', + + # SMS will be sent as flash message (1 = yes, 0 = no) + "flash": 1 if self.flash else 0, + + # Message Simulation + "test": 1 if self.test else 0, + } + + # Set our schema + schema = 'https' if self.secure else 'http' + + # Construct our URL + notify_url = '%s://%s' % (schema, self.host) + if isinstance(self.port, int): + notify_url += ':%d' % self.port + notify_url += self.notify_path + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + notify_by = { + 'phone': { + "method": "sms.send_sms", + 'target': 'to', + }, + 'group': { + "method": "sms.send_togroup", + 'target': 'groupname', + }, + 'contact': { + "method": "sms.send_tocontact", + 'target': 'contactname', + }, + } + + # categories separated into a tuple since notify_by.keys() + # returns an unpredicable list in Python 2.7 which causes + # tests to fail every so often + for category in ('phone', 'group', 'contact'): + # Create a copy of our template + payload = { + 'method': notify_by[category]['method'], + 'params': { + notify_by[category]['target']: None, + }, + } + + # Apply Template + payload['params'].update(params_template) + + # Set our Message + payload["params"]["message"] = "{}{}".format( + '' if not self.status else '{} '.format( + self.asset.ascii(notify_type)), body) + + if attachments: + # Store our attachments + payload['params']['message_type'] = 'mms' + payload['params']['attachments'] = attachments + + targets = getattr(self, 'target_{}s'.format(category)) + for index in range(0, len(targets), batch_size): + # Prepare our recipients + payload['params'][notify_by[category]['target']] = \ + ','.join(targets[index:index + batch_size]) + + self.logger.debug('SMSEagle POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('SMSEagle 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, + ) + + try: + content = loads(r.content) + + # Store our status + status_str = str(content['result']) + + except (AttributeError, TypeError, ValueError, KeyError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + # KeyError = 'result' is not found in result + content = {} + + # The result set can be a list such as: + # b'{"result":[{"message_id":4753,"status":"ok"}]}' + # + # It can also just be as a dictionary: + # b'{"result":{"message_id":4753,"status":"ok"}}' + # + # The below code handles both cases only only fails if a + # non-ok value was returned + + if r.status_code not in ( + requests.codes.ok, requests.codes.created) or \ + not isinstance(content.get('result'), + (dict, list)) or \ + (isinstance(content.get('result'), dict) and + content['result'].get('status') != 'ok') or \ + (isinstance(content.get('result'), list) and + next((True for entry in content.get('result') + if isinstance(entry, dict) and + entry.get('status') != 'ok'), False + ) # pragma: no cover + ): + + # We had a problem + status_str = content.get('result') \ + if content.get('result') else \ + NotifySMSEagle.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send {} {} SMSEagle {} notification: ' + '{}{}error={}.'.format( + len(targets[index:index + batch_size]), + 'to {}'.format(targets[index]) + if batch_size == 1 else '(s)', + category, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response {} Details:\r\n{}'.format( + category.upper(), r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent {} SMSEagle {} notification{}.' + .format( + len(targets[index:index + batch_size]), + category, + ' to {}'.format(targets[index]) + if batch_size == 1 else '(s)', + )) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending {} SMSEagle ' + '{} notification(s).'.format( + len(targets[index:index + batch_size]), category)) + 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 = { + 'batch': 'yes' if self.batch else 'no', + 'status': 'yes' if self.status else 'no', + 'flash': 'yes' if self.flash else 'no', + 'test': 'yes' if self.test else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + default_priority = self.template_args['priority']['default'] + if self.priority is not None: + # Store our priority; but only if it was specified + params['priority'] = \ + next((key for key, value in SMSEAGLE_PRIORITY_MAP.items() + if value == self.priority), + default_priority) # pragma: no cover + + # Default port handling + default_port = 443 if self.secure else 80 + + return '{schema}://{token}@{hostname}{port}/{targets}?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + token=self.pprint( + self.token, privacy, mode=PrivacyMode.Secret, safe=''), + # never encode hostname since we're expecting it to be a valid one + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + targets='/'.join( + [NotifySMSEagle.quote(x, safe='#@') for x in chain( + # Pass phones directly as is + self.target_phones, + # Contacts + ['@{}'.format(x) for x in self.target_contacts], + # Groups + ['#{}'.format(x) for x in self.target_groups], + )]), + params=NotifySMSEagle.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 + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = \ + NotifySMSEagle.split_path(results['fullpath']) + + if 'token' in results['qsd'] and len(results['qsd']['token']): + results['token'] = NotifySMSEagle.unquote(results['qsd']['token']) + + elif not results['password'] and results['user']: + results['token'] = NotifySMSEagle.unquote(results['user']) + + # 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'] += \ + NotifySMSEagle.parse_phone_no(results['qsd']['to']) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get('batch', False)) + + # Get Flash Mode Flag + results['flash'] = \ + parse_bool(results['qsd'].get('flash', False)) + + # Get Test Mode Flag + results['test'] = \ + parse_bool(results['qsd'].get('test', False)) + + # Get status switch + results['status'] = \ + parse_bool(results['qsd'].get('status', False)) + + if 'priority' in results['qsd'] and len(results['qsd']['priority']): + results['priority'] = \ + NotifySMSEagle.unquote(results['qsd']['priority']) + + return results diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index 632a93ea..71e69ed1 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -55,8 +55,9 @@ MessageBird, MQTT, MSG91, MyAndroid, Nexmo, Nextcloud, NextcloudTalk, Notica, Notifico, ntfy, Office365, OneSignal, Opsgenie, PagerDuty, ParsePlatform, PopcornNotify, Prowl, Pushalot, PushBullet, Pushjet, Pushover, PushSafer, Reddit, Rocket.Chat, SendGrid, ServerChan, Signal, SimplePush, Sinch, Slack, -SMTP2Go, Spontit, SparkPost, Super Toasty, Streamlabs, Stride, Syslog, -Techulus Push, Telegram, Twilio, Twitter, Twist, XBMC, Vonage, Webex Teams} +SMSEagle, SMTP2Go, Spontit, SparkPost, Super Toasty, Streamlabs, Stride, +Syslog, Techulus Push, Telegram, Twilio, Twitter, Twist, XBMC, Vonage, Webex +Teams} Name: python-%{pypi_name} Version: 1.0.0 diff --git a/test/test_plugin_smseagle.py b/test/test_plugin_smseagle.py new file mode 100644 index 00000000..c758e932 --- /dev/null +++ b/test/test_plugin_smseagle.py @@ -0,0 +1,736 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 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. +import os +import sys +from json import loads, dumps +try: + # Python 3.x + from unittest import mock + +except ImportError: + # Python 2.7 + import mock + +import requests +from apprise import plugins +from apprise import Apprise +from helpers import AppriseURLTester +from apprise import AppriseAttachment +from apprise import NotifyType + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + +SMSEAGLE_GOOD_RESPONSE = dumps({ + "result": { + "message_id": "748", + "status": "ok" + }}) + +SMSEAGLE_BAD_RESPONSE = dumps({ + "result": { + "error_text": "Wrong parameters", + "status": "error", + }}) + + +# Attachment Directory +TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), 'var') + +# Our Testing URLs +apprise_url_tests = ( + ('smseagle://', { + # No host specified + 'instance': TypeError, + }), + ('smseagle://:@/', { + # invalid host + 'instance': TypeError, + }), + ('smseagle://localhost', { + # Just a host provided (no access token) + 'instance': TypeError, + }), + ('smseagle://%20@localhost', { + # invalid token + 'instance': TypeError, + }), + ('smseagle://token@localhost/123/', { + # invalid 'to' phone number + 'instance': plugins.NotifySMSEagle, + # Notify will fail because it couldn't send to anyone + 'response': False, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'smseagle://****@localhost/@123', + # Our response expected server response + 'requests_response_text': SMSEAGLE_GOOD_RESPONSE, + }), + ('smseagle://token@localhost/%20/%20/', { + # invalid 'to' phone number + 'instance': plugins.NotifySMSEagle, + # Notify will fail because it couldn't send to anyone + 'response': False, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'smseagle://****@localhost/', + # Our response expected server response + 'requests_response_text': SMSEAGLE_GOOD_RESPONSE, + }), + ('smseagle://token@localhost:8080/{}/'.format('1' * 11), { + # one phone number will notify ourselves + 'instance': plugins.NotifySMSEagle, + # Our response expected server response + 'requests_response_text': SMSEAGLE_GOOD_RESPONSE, + }), + ('smseagle://localhost:8080/{}/?token=abc1234'.format('1' * 11), { + # pass our token in as an argument + 'instance': plugins.NotifySMSEagle, + # Our response expected server response + 'requests_response_text': SMSEAGLE_GOOD_RESPONSE, + }), + # Set priority + ('smseagle://token@localhost/@user/?priority=high', { + 'instance': plugins.NotifySMSEagle, + # Our response expected server response + 'requests_response_text': SMSEAGLE_GOOD_RESPONSE, + }), + # Support integer value too + ('smseagle://token@localhost/@user/?priority=1', { + 'instance': plugins.NotifySMSEagle, + 'requests_response_text': SMSEAGLE_GOOD_RESPONSE, + }), + # Invalid priority + ('smseagle://token@localhost/@user/?priority=invalid', { + # Invalid Priority + 'instance': TypeError, + }), + # Invalid priority + ('smseagle://token@localhost/@user/?priority=25', { + # Invalid Priority + 'instance': TypeError, + }), + ('smseagle://token@localhost:8082/#abcd/', { + # a valid group + 'instance': plugins.NotifySMSEagle, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'smseagle://****@localhost:8082/#abcd', + # Our response expected server response + 'requests_response_text': SMSEAGLE_GOOD_RESPONSE, + }), + ('smseagle://token@localhost:8082/@abcd/', { + # a valid contact + 'instance': plugins.NotifySMSEagle, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'smseagle://****@localhost:8082/@abcd', + # Our response expected server response + 'requests_response_text': SMSEAGLE_GOOD_RESPONSE, + }), + ('smseagles://token@localhost:8081/contact/', { + # another valid group (without @ symbol) + 'instance': plugins.NotifySMSEagle, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'smseagles://****@localhost:8081/@contact', + # Our response expected server response + 'requests_response_text': SMSEAGLE_GOOD_RESPONSE, + }), + ('smseagle://token@localhost:8082/@/#/,/', { + # Test case where we provide bad data + 'instance': plugins.NotifySMSEagle, + # Our failed response + 'requests_response_text': SMSEAGLE_GOOD_RESPONSE, + # as a result, we expect a failed notification + 'response': False, + }), + ('smseagle://token@localhost:8083/@user/', { + # Test case where we get a bad response + 'instance': plugins.NotifySMSEagle, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'smseagle://****@localhost:8083/@user', + # Our failed response + 'requests_response_text': SMSEAGLE_BAD_RESPONSE, + # as a result, we expect a failed notification + 'response': False, + }), + ('smseagle://token@localhost:8084/@user/', { + # Test case where we get a bad response + 'instance': plugins.NotifySMSEagle, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'smseagle://****@localhost:8084/@user', + # Our failed response + 'requests_response_text': None, + # as a result, we expect a failed notification + 'response': False, + }), + ('smseagle://token@localhost:8085/@user/', { + # Test case where we get a bad response + 'instance': plugins.NotifySMSEagle, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'smseagle://****@localhost:8085/@user', + # Our failed response (bad json) + 'requests_response_text': '{', + # as a result, we expect a failed notification + 'response': False, + }), + ('smseagle://token@localhost:8086/?to={},{}'.format( + '2' * 11, '3' * 11), { + # use get args to acomplish the same thing + 'instance': plugins.NotifySMSEagle, + # Our response expected server response + 'requests_response_text': SMSEAGLE_GOOD_RESPONSE, + }), + ('smseagle://token@localhost:8087/?to={},{},{}'.format( + '2' * 11, '3' * 11, '5' * 3), { + # 2 good targets and one invalid one + 'instance': plugins.NotifySMSEagle, + # Our response expected server response + 'requests_response_text': SMSEAGLE_GOOD_RESPONSE, + }), + ('smseagle://token@localhost:8088/{}/{}/'.format( + '2' * 11, '3' * 11), { + # If we have from= specified, then all elements take on the to= value + 'instance': plugins.NotifySMSEagle, + # Our response expected server response + 'requests_response_text': SMSEAGLE_GOOD_RESPONSE, + }), + ('smseagles://token@localhost/{}'.format('3' * 11), { + # use get args to acomplish the same thing (use source instead of from) + 'instance': plugins.NotifySMSEagle, + # Our response expected server response + 'requests_response_text': SMSEAGLE_GOOD_RESPONSE, + }), + ('smseagles://token@localhost/{}/{}?batch=True'.format( + '3' * 11, '4' * 11), { + # test batch mode + 'instance': plugins.NotifySMSEagle, + # Our response expected server response + 'requests_response_text': SMSEAGLE_GOOD_RESPONSE, + }), + ('smseagles://token@localhost/{}/?flash=yes'.format( + '3' * 11), { + # test flash mode + 'instance': plugins.NotifySMSEagle, + # Our response expected server response + 'requests_response_text': SMSEAGLE_GOOD_RESPONSE, + }), + ('smseagles://token@localhost/{}/?test=yes'.format( + '3' * 11), { + # test mode + 'instance': plugins.NotifySMSEagle, + # Our response expected server response + 'requests_response_text': SMSEAGLE_GOOD_RESPONSE, + }), + ('smseagles://token@localhost/{}/{}?status=True'.format( + '3' * 11, '4' * 11), { + # test status switch + 'instance': plugins.NotifySMSEagle, + # Our response expected server response + 'requests_response_text': SMSEAGLE_GOOD_RESPONSE, + }), + ('smseagle://token@localhost/{}'.format('4' * 11), { + 'instance': plugins.NotifySMSEagle, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + # Our response expected server response + 'requests_response_text': SMSEAGLE_GOOD_RESPONSE, + }), + ('smseagle://token@localhost/{}'.format('4' * 11), { + 'instance': plugins.NotifySMSEagle, + # 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_smseagle_urls(): + """ + NotifySMSEagle() Apprise URLs + + """ + + # Run our general tests + AppriseURLTester(tests=apprise_url_tests).run_all() + + +@mock.patch('requests.post') +def test_plugin_smseagle_edge_cases(mock_post): + """ + NotifySMSEagle() Edge Cases + + """ + # Disable Throttling to speed testing + plugins.NotifyBase.request_rate_per_sec = 0 + + # Prepare our response + response = requests.Request() + response.status_code = requests.codes.ok + response.content = SMSEAGLE_GOOD_RESPONSE + + # Prepare Mock + mock_post.return_value = response + + # Initialize some generic (but valid) tokens + target = '+1 (555) 987-5432' + body = "test body" + title = "My Title" + + aobj = Apprise() + assert aobj.add( + "smseagles://token@localhost:231/{}".format(target)) + assert aobj.notify(title=title, body=body) + + assert mock_post.call_count == 1 + + details = mock_post.call_args_list[0] + assert details[0][0] == 'https://localhost:231/jsonrpc/sms' + payload = loads(details[1]['data']) + assert payload['params']['message'] == 'My Title\r\ntest body' + + # Reset our mock object + mock_post.reset_mock() + + aobj = Apprise() + assert aobj.add( + "smseagles://token@localhost:231/{}?status=Yes".format( + target)) + assert aobj.notify(title=title, body=body) + + assert mock_post.call_count == 1 + + details = mock_post.call_args_list[0] + assert details[0][0] == 'https://localhost:231/jsonrpc/sms' + payload = loads(details[1]['data']) + # Status flag is set + assert payload['params']['message'] == '[i] My Title\r\ntest body' + + +@mock.patch('requests.post') +def test_plugin_smseagle_result_set(mock_post): + """ + NotifySMSEagle() Result Sets + + """ + # Disable Throttling to speed testing + plugins.NotifyBase.request_rate_per_sec = 0 + + # Prepare our response + response = requests.Request() + response.status_code = requests.codes.ok + response.content = SMSEAGLE_GOOD_RESPONSE + + # Prepare Mock + mock_post.return_value = response + + body = "test body" + title = "My Title" + + aobj = Apprise() + aobj.add( + 'smseagle://token@10.0.0.112:8080/+12512222222/+12513333333/' + '12514444444?batch=yes') + + assert aobj.notify(title=title, body=body) + + # If a batch, there is only 1 post + assert mock_post.call_count == 1 + + details = mock_post.call_args_list[0] + assert details[0][0] == 'http://10.0.0.112:8080/jsonrpc/sms' + payload = loads(details[1]['data']) + assert 'method' in payload + assert payload['method'] == 'sms.send_sms' + + assert 'params' in payload + assert isinstance(payload['params'], dict) + params = payload['params'] + assert 'to' in params + assert len(params['to'].split(',')) == 3 + + assert "+12512222222" in params['to'].split(',') + assert "+12513333333" in params['to'].split(',') + # The + is not appended + assert "12514444444" in params['to'].split(',') + assert params.get('message_type') == 'sms' + assert params.get('responsetype') == 'extended' + assert params.get('access_token') == 'token' + assert params.get('highpriority') == 0 + assert params.get('flash') == 0 + assert params.get('test') == 0 + assert params.get('unicode') == 1 + assert params.get('message') == 'My Title\r\ntest body' + + # Reset our test and turn batch mode off + mock_post.reset_mock() + + aobj = Apprise() + aobj.add( + 'smseagle://token@10.0.0.112:8080/#group/Contact/' + '123456789?batch=no') + + assert aobj.notify(title=title, body=body) + + # If batch is off then there is a post per entry + assert mock_post.call_count == 3 + + details = mock_post.call_args_list[0] + assert details[0][0] == 'http://10.0.0.112:8080/jsonrpc/sms' + payload = loads(details[1]['data']) + assert payload['method'] == 'sms.send_sms' + + assert 'params' in payload + assert isinstance(payload['params'], dict) + params = payload['params'] + assert 'to' in params + assert len(params['to'].split(',')) == 1 + assert "123456789" in params['to'].split(',') + + assert params.get('message_type') == 'sms' + assert params.get('responsetype') == 'extended' + assert params.get('access_token') == 'token' + assert params.get('highpriority') == 0 + assert params.get('flash') == 0 + assert params.get('test') == 0 + assert params.get('unicode') == 1 + assert params.get('message') == 'My Title\r\ntest body' + + details = mock_post.call_args_list[1] + assert details[0][0] == 'http://10.0.0.112:8080/jsonrpc/sms' + payload = loads(details[1]['data']) + assert payload['method'] == 'sms.send_togroup' + + assert 'params' in payload + assert isinstance(payload['params'], dict) + params = payload['params'] + assert 'groupname' in params + assert len(params['groupname'].split(',')) == 1 + assert "group" in params['groupname'].split(',') + + assert params.get('message_type') == 'sms' + assert params.get('responsetype') == 'extended' + assert params.get('access_token') == 'token' + assert params.get('highpriority') == 0 + assert params.get('flash') == 0 + assert params.get('test') == 0 + assert params.get('unicode') == 1 + assert params.get('message') == 'My Title\r\ntest body' + + details = mock_post.call_args_list[2] + assert details[0][0] == 'http://10.0.0.112:8080/jsonrpc/sms' + payload = loads(details[1]['data']) + assert payload['method'] == 'sms.send_tocontact' + + assert 'params' in payload + assert isinstance(payload['params'], dict) + params = payload['params'] + assert 'contactname' in params + assert len(params['contactname'].split(',')) == 1 + assert "Contact" in params['contactname'].split(',') + + assert params.get('message_type') == 'sms' + assert params.get('responsetype') == 'extended' + assert params.get('access_token') == 'token' + assert params.get('highpriority') == 0 + assert params.get('flash') == 0 + assert params.get('test') == 0 + assert params.get('unicode') == 1 + assert params.get('message') == 'My Title\r\ntest body' + + mock_post.reset_mock() + + # Test groups and contact names + aobj = Apprise() + aobj.add( + 'smseagle://token@10.0.0.112:8080/513333333/#group1/@contact1/' + 'contact2/12514444444?batch=yes') + + assert aobj.notify(title=title, body=body) + + # There is a unique post to each (group, contact x2, and phone x2) + # The key is the contacts were grouped here in 1 post each + assert mock_post.call_count == 3 + + details = mock_post.call_args_list[0] + assert details[0][0] == 'http://10.0.0.112:8080/jsonrpc/sms' + payload = loads(details[1]['data']) + assert payload['method'] == 'sms.send_sms' + + assert 'params' in payload + assert isinstance(payload['params'], dict) + params = payload['params'] + assert 'to' in params + assert len(params['to'].split(',')) == 2 + assert "513333333" in params['to'].split(',') + assert "12514444444" in params['to'].split(',') + + assert params.get('message_type') == 'sms' + assert params.get('responsetype') == 'extended' + assert params.get('access_token') == 'token' + assert params.get('highpriority') == 0 + assert params.get('flash') == 0 + assert params.get('test') == 0 + assert params.get('unicode') == 1 + assert params.get('message') == 'My Title\r\ntest body' + + details = mock_post.call_args_list[1] + assert details[0][0] == 'http://10.0.0.112:8080/jsonrpc/sms' + payload = loads(details[1]['data']) + assert payload['method'] == 'sms.send_togroup' + + assert 'params' in payload + assert isinstance(payload['params'], dict) + params = payload['params'] + assert 'groupname' in params + assert len(params['groupname'].split(',')) == 1 + assert "group1" in params['groupname'].split(',') + + assert params.get('message_type') == 'sms' + assert params.get('responsetype') == 'extended' + assert params.get('access_token') == 'token' + assert params.get('highpriority') == 0 + assert params.get('flash') == 0 + assert params.get('test') == 0 + assert params.get('unicode') == 1 + assert params.get('message') == 'My Title\r\ntest body' + + details = mock_post.call_args_list[2] + assert details[0][0] == 'http://10.0.0.112:8080/jsonrpc/sms' + payload = loads(details[1]['data']) + assert payload['method'] == 'sms.send_tocontact' + + assert 'params' in payload + assert isinstance(payload['params'], dict) + params = payload['params'] + assert 'contactname' in params + assert len(params['contactname'].split(',')) == 2 + assert "contact1" in params['contactname'].split(',') + assert "contact2" in params['contactname'].split(',') + + assert params.get('message_type') == 'sms' + assert params.get('responsetype') == 'extended' + assert params.get('access_token') == 'token' + assert params.get('highpriority') == 0 + assert params.get('flash') == 0 + assert params.get('test') == 0 + assert params.get('unicode') == 1 + assert params.get('message') == 'My Title\r\ntest body' + + # Validate our information is also placed back into the assembled URL + assert '/@contact1' in aobj[0].url() + assert '/@contact2' in aobj[0].url() + assert '/#group1' in aobj[0].url() + assert '/513333333' in aobj[0].url() + assert '/12514444444' in aobj[0].url() + + +@mock.patch('requests.post') +def test_notify_smseagle_plugin_result_list(mock_post): + """ + NotifySMSEagle() Result List Response + + """ + # Disable Throttling to speed testing + plugins.NotifyBase.request_rate_per_sec = 0 + + okay_response = requests.Request() + okay_response.status_code = requests.codes.ok + # We want to test the case where the `result` set returned is a list + okay_response.content = dumps({ + "result": [{ + "message_id": "748", + "status": "ok" + }]}) + + # Assign our mock object our return value + mock_post.return_value = okay_response + + obj = Apprise.instantiate('smseagle://token@127.0.0.1/12222222/') + assert isinstance(obj, plugins.NotifySMSEagle) + + # We should successfully handle the list + assert obj.notify("test") is True + + # However if one of the elements in the list is bad + okay_response.content = dumps({ + "result": [{ + "message_id": "748", + "status": "ok" + }, { + "message_id": "749", + "status": "error" + }]}) + + # Assign our mock object our return value + mock_post.return_value = okay_response + + # We should now fail + assert obj.notify("test") is False + + +@mock.patch('requests.post') +def test_notify_smseagle_plugin_attachments(mock_post): + """ + NotifySMSEagle() Attachments + + """ + # Disable Throttling to speed testing + plugins.NotifyBase.request_rate_per_sec = 0 + + okay_response = requests.Request() + okay_response.status_code = requests.codes.ok + okay_response.content = SMSEAGLE_GOOD_RESPONSE + + # Assign our mock object our return value + mock_post.return_value = okay_response + + obj = Apprise.instantiate( + 'smseagle://token@10.0.0.112:8080/+12512222222/+12513333333/' + '12514444444?batch=no') + assert isinstance(obj, plugins.NotifySMSEagle) + + # Test Valid Attachment + path = os.path.join(TEST_VAR_DIR, 'apprise-test.gif') + attach = AppriseAttachment(path) + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is True + + # Test invalid attachment + path = os.path.join(TEST_VAR_DIR, '/invalid/path/to/an/invalid/file.jpg') + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=path) is False + + # Get a appropriate "builtin" module name for pythons 2/3. + if sys.version_info.major >= 3: + builtin_open_function = 'builtins.open' + + else: + builtin_open_function = '__builtin__.open' + + # Test Valid Attachment (load 3) + path = ( + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + ) + attach = AppriseAttachment(path) + + # Return our good configuration + mock_post.side_effect = None + mock_post.return_value = okay_response + with mock.patch(builtin_open_function, side_effect=OSError()): + # We can't send the message we can't open the attachment for reading + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is False + + # test the handling of our batch modes + obj = Apprise.instantiate( + 'smseagle://token@10.0.0.112:8080/+12512222222/+12513333333/' + '12514444444?batch=yes') + assert isinstance(obj, plugins.NotifySMSEagle) + + # Now send an attachment normally without issues + mock_post.reset_mock() + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is True + + # Verify we posted upstream + assert mock_post.call_count == 1 + + details = mock_post.call_args_list[0] + assert details[0][0] == 'http://10.0.0.112:8080/jsonrpc/sms' + payload = loads(details[1]['data']) + assert payload['method'] == 'sms.send_sms' + + assert 'params' in payload + assert isinstance(payload['params'], dict) + params = payload['params'] + assert 'to' in params + assert len(params['to'].split(',')) == 3 + assert "+12512222222" in params['to'].split(',') + assert "+12513333333" in params['to'].split(',') + assert "12514444444" in params['to'].split(',') + + assert params.get('message_type') == 'mms' + assert params.get('responsetype') == 'extended' + assert params.get('access_token') == 'token' + assert params.get('highpriority') == 0 + assert params.get('flash') == 0 + assert params.get('test') == 0 + assert params.get('unicode') == 1 + assert params.get('message') == 'title\r\nbody' + + # Verify our attachments are in place + assert 'attachments' in params + assert isinstance(params['attachments'], list) + assert len(params['attachments']) == 3 + for entry in params['attachments']: + assert 'content' in entry + assert 'content_type' in entry + assert entry.get('content_type').startswith('image/') + + # Reset our mock object + mock_post.reset_mock() + + # test the handling of our batch modes + obj = Apprise.instantiate( + 'smseagle://token@10.0.0.112:8080/513333333/') + assert isinstance(obj, plugins.NotifySMSEagle) + + # Unsupported (non image types are not sent) + attach = os.path.join(TEST_VAR_DIR, 'apprise-test.mp4') + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is True + + # Verify we still posted upstream + assert mock_post.call_count == 1 + + details = mock_post.call_args_list[0] + assert details[0][0] == 'http://10.0.0.112:8080/jsonrpc/sms' + payload = loads(details[1]['data']) + assert payload['method'] == 'sms.send_sms' + + assert 'params' in payload + assert isinstance(payload['params'], dict) + params = payload['params'] + assert 'to' in params + assert len(params['to'].split(',')) == 1 + assert "513333333" in params['to'].split(',') + + assert params.get('message_type') == 'sms' + assert params.get('responsetype') == 'extended' + assert params.get('access_token') == 'token' + assert params.get('highpriority') == 0 + assert params.get('flash') == 0 + assert params.get('test') == 0 + assert params.get('unicode') == 1 + assert params.get('message') == 'title\r\nbody' + + # No attachments were added + assert 'attachments' not in params