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