diff --git a/KEYWORDS b/KEYWORDS index 75eb2ff41..ebd312e34 100644 --- a/KEYWORDS +++ b/KEYWORDS @@ -2,6 +2,7 @@ Alerts API AWS Boxcar +BulkSMS Chat CLI ClickSend diff --git a/README.md b/README.md index 7e1b3503f..bbc629188 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ The table below identifies the services this tool supports and some example serv | Notification Service | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | | [AWS SNS](https://github.com/caronc/apprise/wiki/Notify_sns) | sns:// | (TCP) 443 | sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo
sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo1/+PhoneNo2/+PhoneNoN
sns://AccessKeyID/AccessSecretKey/RegionName/Topic
sns://AccessKeyID/AccessSecretKey/RegionName/Topic1/Topic2/TopicN +| [BulkSMS](https://github.com/caronc/apprise/wiki/Notify_bulksms) | bulksms:// | (TCP) 443 | bulksms://user:password@ToPhoneNo
bulksms://User:Password@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [ClickSend](https://github.com/caronc/apprise/wiki/Notify_clicksend) | clicksend:// | (TCP) 443 | clicksend://user:pass@PhoneNo
clicksend://user:pass@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [DAPNET](https://github.com/caronc/apprise/wiki/Notify_dapnet) | dapnet:// | (TCP) 80 | dapnet://user:pass@callsign
dapnet://user:pass@callsign1/callsign2/callsignN | [D7 Networks](https://github.com/caronc/apprise/wiki/Notify_d7networks) | d7sms:// | (TCP) 443 | d7sms://user:pass@PhoneNo
d7sms://user:pass@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN diff --git a/apprise/plugins/NotifyBulkSMS.py b/apprise/plugins/NotifyBulkSMS.py new file mode 100644 index 000000000..8fa546421 --- /dev/null +++ b/apprise/plugins/NotifyBulkSMS.py @@ -0,0 +1,457 @@ +# -*- 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. + +# To use this service you will need a BulkSMS account +# You will need credits (new accounts start with a few) +# https://www.bulksms.com/account/ +# +# API is documented here: +# - https://www.bulksms.com/developer/json/v1/#tag/Message +import re +import six +import requests +import json +from itertools import chain +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import is_phone_no +from ..utils import parse_phone_no +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + + +IS_GROUP_RE = re.compile( + r'^(@?(?P[A-Z0-9_-]+))$', + re.IGNORECASE, +) + + +class BulkSMSRoutingGroup(object): + """ + The different categories of routing + """ + ECONOMY = "ECONOMY" + STANDARD = "STANDARD" + PREMIUM = "PREMIUM" + + +# Used for verification purposes +BULKSMS_ROUTING_GROUPS = ( + BulkSMSRoutingGroup.ECONOMY, + BulkSMSRoutingGroup.STANDARD, + BulkSMSRoutingGroup.PREMIUM, +) + + +class BulkSMSEncoding(object): + """ + The different categories of routing + """ + TEXT = "TEXT" + UNICODE = "UNICODE" + BINARY = "BINARY" + + +class NotifyBulkSMS(NotifyBase): + """ + A wrapper for BulkSMS Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'BulkSMS' + + # The services URL + service_url = 'https://bulksms.com/' + + # All notification requests are secure + secure_protocol = 'bulksms' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_bulksms' + + # BulkSMS uses the http protocol with JSON requests + notify_url = 'https://api.bulksms.com/v1/messages' + + # The maximum length of the body + body_maxlen = 160 + + # The maximum amount of texts that can go out in one batch + default_batch_size = 4000 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{user}:{password}@{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'user': { + 'name': _('User Name'), + 'type': 'string', + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'target_group': { + 'name': _('Target Group'), + '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', + }, + 'from': { + 'name': _('From Phone No'), + 'type': 'string', + 'regex': (r'^\+?[0-9\s)(+-]+$', 'i'), + 'map_to': 'source', + }, + 'route': { + 'name': _('Route Group'), + 'type': 'choice:string', + 'values': BULKSMS_ROUTING_GROUPS, + 'default': BulkSMSRoutingGroup.STANDARD, + }, + 'unicode': { + # Unicode characters + 'name': _('Unicode Characters'), + 'type': 'bool', + 'default': True, + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + }) + + def __init__(self, source=None, targets=None, unicode=None, batch=None, + route=None, **kwargs): + """ + Initialize BulkSMS Object + """ + super(NotifyBulkSMS, self).__init__(**kwargs) + + self.source = None + if source: + result = is_phone_no(source) + if not result: + msg = 'The Account (From) Phone # specified ' \ + '({}) is invalid.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # Tidy source + self.source = '+{}'.format(result['full']) + + # Setup our route + self.route = self.template_args['route']['default'] \ + if not isinstance(route, six.string_types) else route.upper() + if self.route not in BULKSMS_ROUTING_GROUPS: + msg = 'The route specified ({}) is invalid.'.format(route) + self.logger.warning(msg) + raise TypeError(msg) + + # Define whether or not we should set the unicode flag + self.unicode = self.template_args['unicode']['default'] \ + if unicode is None else bool(unicode) + + # Define whether or not we should operate in a batch mode + self.batch = self.template_args['batch']['default'] \ + if batch is None else bool(batch) + + # Parse our targets + self.targets = list() + self.groups = list() + + for target in parse_phone_no(targets): + # Parse each phone number we found + result = is_phone_no(target) + if result: + self.targets.append('+{}'.format(result['full'])) + continue + + group_re = IS_GROUP_RE.match(target) + if group_re and not target.isdigit(): + # If the target specified is all digits, it MUST have a @ + # in front of it to eliminate any ambiguity + self.groups.append(group_re.group('group')) + continue + + self.logger.warning( + 'Dropped invalid phone # and/or Group ' + '({}) specified.'.format(target), + ) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform BulkSMS Notification + """ + + if not (self.password and self.user): + self.logger.warning( + 'There were no valid login credentials provided') + return False + + if not (self.targets or self.groups): + # We have nothing to notify + self.logger.warning('There are no Twist targets to notify') + return False + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + # Prepare our payload + payload = { + # The To gets populated in the loop below + 'to': None, + 'body': body, + 'routingGroup': self.route, + 'encoding': BulkSMSEncoding.UNICODE \ + if self.unicode else BulkSMSEncoding.TEXT, + # Options are NONE, ALL and ERRORS + 'deliveryReports': "ERRORS" + } + + if self.source: + payload.update({ + 'from': self.source, + }) + + # Authentication + auth = (self.user, self.password) + + # Prepare our targets + targets = list(self.targets) if batch_size == 1 else \ + [self.targets[index:index + batch_size] + for index in range(0, len(self.targets), batch_size)] + targets += [{"type": "GROUP", "name": g} for g in self.groups] + + while len(targets): + # Get our target to notify + target = targets.pop(0) + + # Prepare our user + payload['to'] = target + + # Printable reference + if isinstance(target, dict): + p_target = target['name'] + + elif isinstance(target, list): + p_target = '{} targets'.format(len(target)) + + else: + p_target = target + + # Some Debug Logging + self.logger.debug('BulkSMS POST URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('BulkSMS Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_url, + data=json.dumps(payload), + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # The responsne might look like: + # [ + # { + # "id": "string", + # "type": "SENT", + # "from": "string", + # "to": "string", + # "body": null, + # "encoding": "TEXT", + # "protocolId": 0, + # "messageClass": 0, + # "numberOfParts": 0, + # "creditCost": 0, + # "submission": {...}, + # "status": {...}, + # "relatedSentMessageId": "string", + # "userSuppliedId": "string" + # } + # ] + + if r.status_code not in ( + requests.codes.created, requests.codes.ok): + # We had a problem + status_str = \ + NotifyBase.http_response_code_lookup(r.status_code) + + # set up our status code to use + status_code = r.status_code + + self.logger.warning( + 'Failed to send BulkSMS notification to {}: ' + '{}{}error={}.'.format( + p_target, + status_str, + ', ' if status_str else '', + status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent BulkSMS notification to {}.'.format(p_target)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending BulkSMS: to %s ', + p_target) + 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 = { + 'unicode': 'yes' if self.unicode else 'no', + 'batch': 'yes' if self.batch else 'no', + 'route': self.route, + } + + if self.source: + params['from'] = self.source + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{user}:{password}@{targets}/?{params}'.format( + schema=self.secure_protocol, + user=self.pprint(self.user, privacy, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + targets='/'.join(chain( + [NotifyBulkSMS.quote('{}'.format(x), safe='+') + for x in self.targets], + [NotifyBulkSMS.quote('@{}'.format(x), safe='@') + for x in self.groups])), + params=NotifyBulkSMS.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'] = [ + NotifyBulkSMS.unquote(results['host']), + *NotifyBulkSMS.split_path(results['fullpath'])] + + # 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'] = \ + NotifyBulkSMS.unquote(results['qsd']['from']) + + # 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'] += \ + NotifyBulkSMS.parse_phone_no(results['qsd']['to']) + + # Unicode Characters + results['unicode'] = \ + parse_bool(results['qsd'].get( + 'unicode', NotifyBulkSMS.template_args['unicode']['default'])) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get( + 'batch', NotifyBulkSMS.template_args['batch']['default'])) + + # Allow one to define a route group + if 'route' in results['qsd'] and len(results['qsd']['route']): + results['route'] = \ + NotifyBulkSMS.unquote(results['qsd']['route']) + + return results diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index f4877cd7f..60e1d1be4 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -35,17 +35,17 @@ Apprise is a Python package for simplifying access to all of the different notification services that are out there. Apprise opens the door and makes it easy to access: -Apprise API, AWS SES, AWS SNS, Bark, Boxcar, ClickSend, DAPNET, DingTalk, -Discord, E-Mail, Emby, Faast, FCM, Flock, Gitter, Google Chat, Gotify, Growl, -Guilded, Home Assistant, IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, -Line, MacOSX, Mailgun, Mattermost, Matrix, Microsoft Windows, Microsoft Teams, -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, -SMSEagle, SMTP2Go, Spontit, SparkPost, Super Toasty, Streamlabs, Stride, -Syslog, Techulus Push, Telegram, Twilio, Twitter, Twist, XBMC, Vonage, Webex -Teams} +Apprise API, AWS SES, AWS SNS, Bark, BulkSMS, Boxcar, ClickSend, DAPNET, +DingTalk, Discord, E-Mail, Emby, Faast, FCM, Flock, Gitter, Google Chat, +Gotify, Growl, Guilded, Home Assistant, IFTTT, Join, Kavenegar, KODI, Kumulos, +LaMetric, Line, MacOSX, Mailgun, Mattermost, Matrix, Microsoft Windows, +Microsoft Teams, 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, 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_bulksms.py b/test/test_plugin_bulksms.py new file mode 100644 index 000000000..7c56de65e --- /dev/null +++ b/test/test_plugin_bulksms.py @@ -0,0 +1,207 @@ +# -*- 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. +try: + # Python 3.x + from unittest import mock + +except ImportError: + # Python 2.7 + import mock + +import requests +from json import loads +from apprise import Apprise +from apprise import plugins +from helpers import AppriseURLTester +from apprise import NotifyType + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + +# Our Testing URLs +apprise_url_tests = ( + ('bulksms://', { + # Instantiated but no auth, so no otification can happen + 'instance': plugins.NotifyBulkSMS, + # Expected notify() response because we have no one to notify + 'notify_response': False, + }), + ('bulksms://:@/', { + # invalid auth + 'instance': plugins.NotifyBulkSMS, + # Expected notify() response because we have no one to notify + 'notify_response': False, + }), + ('bulksms://{}@12345678'.format('a' * 10), { + # Just user provided (no password) + 'instance': plugins.NotifyBulkSMS, + # Expected notify() response because we have no one to notify + 'notify_response': False, + }), + ('bulksms://{}:{}@{}'.format('a' * 10, 'b' * 10, '3' * 5), { + # invalid nubmer provided + 'instance': plugins.NotifyBulkSMS, + # Expected notify() response because we have no one to notify + 'notify_response': False, + }), + ('bulksms://{}:{}@123/{}/abcd/'.format( + 'a' * 5, 'b' * 10, '3' * 11), { + # included group and phone, short number (123) dropped + 'instance': plugins.NotifyBulkSMS, + 'privacy_url': 'bulksms://a...a:****@+33333333333/@abcd' + }), + ('bulksms://{}:{}@{}?batch=y&unicode=n'.format( + 'b' * 5, 'c' * 10, '4' * 11), { + 'instance': plugins.NotifyBulkSMS, + + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'bulksms://b...b:****@+4444444444', + }), + ('bulksms://{}:{}@123456/{}'.format('a' * 10, 'b' * 10, '4' * 11), { + # using short-code (6 characters) + 'instance': plugins.NotifyBulkSMS, + }), + ('bulksms://{}:{}@{}'.format('a' * 10, 'b' * 10, '5' * 11), { + # using phone no with no target - we text ourselves in + # this case + 'instance': plugins.NotifyBulkSMS, + }), + # Test route group + ('bulksms://{}:{}@admin?route=premium'.format('a' * 10, 'b' * 10), { + 'instance': plugins.NotifyBulkSMS, + }), + ('bulksms://{}:{}@admin?route=invalid'.format('a' * 10, 'b' * 10), { + # invalid route + 'instance': TypeError, + }), + ('bulksms://_?user={}&password={}&from={}'.format( + 'a' * 10, 'b' * 10, '5' * 11), { + # use get args to acomplish the same thing + 'instance': plugins.NotifyBulkSMS, + }), + ('bulksms://_?user={}&password={}&from={}'.format( + 'a' * 10, 'b' * 10, '5' * 3), { + # use get args to acomplish the same thing + 'instance': TypeError, + }), + ('bulksms://_?user={}&password={}&from={}&to={}'.format( + 'a' * 10, 'b' * 10, '5' * 11, '7' * 13), { + # use to= + 'instance': plugins.NotifyBulkSMS, + }), + ('bulksms://{}:{}@{}'.format('a' * 10, 'b' * 10, 'a' * 3), { + 'instance': plugins.NotifyBulkSMS, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('bulksms://{}:{}@{}'.format('a' * 10, 'b' * 10, '6' * 11), { + 'instance': plugins.NotifyBulkSMS, + # 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_bulksms_urls(): + """ + NotifyTemplate() Apprise URLs + + """ + + # Run our general tests + AppriseURLTester(tests=apprise_url_tests).run_all() + + +@mock.patch('requests.post') +def test_plugin_bulksms_edge_cases(mock_post): + """ + NotifyBulkSMS() Edge Cases + + """ + # Disable Throttling to speed testing + plugins.NotifyBase.request_rate_per_sec = 0 + + # Initialize some generic (but valid) tokens + user = 'abcd' + pwd = 'mypass123' + targets = [ + '+1(555) 123-1234', + '1555 5555555', + 'group', + # A garbage entry + '12', + # NOw a valid one because a group was implicit + '@12', + ] + + # Prepare our response + response = requests.Request() + response.status_code = requests.codes.ok + + # Prepare Mock + mock_post.return_value = response + + # Test our markdown + obj = Apprise.instantiate( + 'bulksms://{}:{}@{}?batch=n'.format(user, pwd, '/'.join(targets))) + + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is True + + # Test our call count + assert mock_post.call_count == 4 + + # Test + details = mock_post.call_args_list[0] + payload = loads(details[1]['data']) + assert payload['to'] == '+15551231234' + assert payload['body'] == 'title\r\nbody' + + details = mock_post.call_args_list[1] + payload = loads(details[1]['data']) + assert payload['to'] == '+15555555555' + assert payload['body'] == 'title\r\nbody' + + details = mock_post.call_args_list[2] + payload = loads(details[1]['data']) + assert isinstance(payload['to'], dict) + assert payload['to']['name'] == 'group' + assert payload['body'] == 'title\r\nbody' + + details = mock_post.call_args_list[3] + payload = loads(details[1]['data']) + assert isinstance(payload['to'], dict) + assert payload['to']['name'] == '12' + assert payload['body'] == 'title\r\nbody' + + # Verify our URL looks good + assert obj.url().startswith( + 'bulksms://{}:{}@{}'.format(user, pwd, '/'.join( + ['+15551231234', '+15555555555', '@group', '@12']))) + + assert 'batch=no' in obj.url()