diff --git a/KEYWORDS b/KEYWORDS index 6ee6c93c..4ebf6f2c 100644 --- a/KEYWORDS +++ b/KEYWORDS @@ -1,3 +1,4 @@ +Africas Talking Alerts Apprise API Automated Packet Reporting System diff --git a/README.md b/README.md index 5346b799..04ac4baa 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ The table below identifies the services this tool supports and some example serv | Notification Service | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | +| [Africas Talking](https://github.com/caronc/apprise/wiki/Notify_africas_talking) | atalk:// | (TCP) 443 | atalk://AppUser@ApiKey/ToPhoneNo
atalk://AppUser@ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Automated Packet Reporting System (ARPS)](https://github.com/caronc/apprise/wiki/Notify_aprs) | aprs:// | (TCP) 10152 | aprs://user:pass@callsign
aprs://user:pass@callsign1/callsign2/callsignN | [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/ @@ -149,7 +150,7 @@ The table below identifies the services this tool supports and some example serv | [D7 Networks](https://github.com/caronc/apprise/wiki/Notify_d7networks) | d7sms:// | (TCP) 443 | d7sms://token@PhoneNo
d7sms://token@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [DingTalk](https://github.com/caronc/apprise/wiki/Notify_dingtalk) | dingtalk:// | (TCP) 443 | dingtalk://token/
dingtalk://token/ToPhoneNo
dingtalk://token/ToPhoneNo1/ToPhoneNo2/ToPhoneNo1/ | [Free-Mobile](https://github.com/caronc/apprise/wiki/Notify_freemobile) | freemobile:// | (TCP) 443 | freemobile://user@password/ - [httpSMS](https://github.com/caronc/apprise/wiki/Notify_httpsms) | httpsms:// | (TCP) 443 | httpsms://ApiKey@FromPhoneNo
httpsms://ApiKey@FromPhoneNo/ToPhoneNo
httpsms://ApiKey@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ +| [httpSMS](https://github.com/caronc/apprise/wiki/Notify_httpsms) | httpsms:// | (TCP) 443 | httpsms://ApiKey@FromPhoneNo
httpsms://ApiKey@FromPhoneNo/ToPhoneNo
httpsms://ApiKey@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Kavenegar](https://github.com/caronc/apprise/wiki/Notify_kavenegar) | kavenegar:// | (TCP) 443 | kavenegar://ApiKey/ToPhoneNo
kavenegar://FromPhoneNo@ApiKey/ToPhoneNo
kavenegar://ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [MessageBird](https://github.com/caronc/apprise/wiki/Notify_messagebird) | msgbird:// | (TCP) 443 | msgbird://ApiKey/FromPhoneNo
msgbird://ApiKey/FromPhoneNo/ToPhoneNo
msgbird://ApiKey/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [MSG91](https://github.com/caronc/apprise/wiki/Notify_msg91) | msg91:// | (TCP) 443 | msg91://TemplateID@AuthKey/ToPhoneNo
msg91://TemplateID@AuthKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ @@ -157,7 +158,7 @@ The table below identifies the services this tool supports and some example serv | [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/ - [SMS Manager](https://github.com/caronc/apprise/wiki/Notify_sms_manager) | smsmgr:// | (TCP) 443 | smsmgr://ApiKey@ToPhoneNo
smsmgr://ApiKey@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ +| [SMS Manager](https://github.com/caronc/apprise/wiki/Notify_sms_manager) | smsmgr:// | (TCP) 443 | smsmgr://ApiKey@ToPhoneNo
smsmgr://ApiKey@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Threema Gateway](https://github.com/caronc/apprise/wiki/Notify_threema) | threema:// | (TCP) 443 | threema://GatewayID@secret/ToPhoneNo
threema://GatewayID@secret/ToEmail
threema://GatewayID@secret/ToThreemaID/
threema://GatewayID@secret/ToEmail/ToThreemaID/ToPhoneNo/... | [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/ | [Voipms](https://github.com/caronc/apprise/wiki/Notify_voipms) | voipms:// | (TCP) 443 | voipms://password:email/FromPhoneNo
voipms://password:email/FromPhoneNo/ToPhoneNo
voipms://password:email/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ diff --git a/apprise/plugins/africas_talking.py b/apprise/plugins/africas_talking.py new file mode 100644 index 00000000..6d67e510 --- /dev/null +++ b/apprise/plugins/africas_talking.py @@ -0,0 +1,461 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +# To use this plugin, you must have a Africas Talking Account setup; See here: +# https://account.africastalking.com/ +# From here... acquire your APIKey +# +# API Details: https://developers.africastalking.com/docs/sms/sending/bulk +import requests + +from .base import NotifyBase +from ..common import NotifyType +from ..utils import is_phone_no +from ..utils import parse_bool +from ..utils import parse_phone_no +from ..utils import validate_regex +from ..locale import gettext_lazy as _ + + +class AfricasTalkingSMSMode: + """ + Africas Talking SMS Mode + """ + # BulkSMS Mode + BULKSMS = 'bulksms' + + # Premium Mode + PREMIUM = 'premium' + + # Sandbox Mode + SANDBOX = 'sandbox' + + +# Define the types in a list for validation purposes +AFRICAS_TALKING_SMS_MODES = ( + AfricasTalkingSMSMode.BULKSMS, + AfricasTalkingSMSMode.PREMIUM, + AfricasTalkingSMSMode.SANDBOX, +) + + +# Extend HTTP Error Messages +AFRICAS_TALKING_HTTP_ERROR_MAP = { + 100: 'Processed', + 101: 'Sent', + 102: 'Queued', + 401: 'Risk Hold', + 402: 'Invalid Sender ID', + 403: 'Invalid Phone Number', + 404: 'Unsupported Number Type', + 405: 'Insufficient Balance', + 406: 'User In Blacklist', + 407: 'Could Not Route', + 409: 'Do Not Disturb Rejection', + 500: 'Internal Server Error', + 501: 'Gateway Error', + 502: 'Rejected By Gateway', +} + + +class NotifyAfricasTalking(NotifyBase): + """ + A wrapper for Africas Talking Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Africas Talking' + + # The services URL + service_url = 'https://africastalking.com/' + + # The default secure protocol + secure_protocol = 'atalk' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_africas_talking' + + # Africas Talking API Request URLs + notify_url = { + AfricasTalkingSMSMode.BULKSMS: + 'https://api.africastalking.com/version1/messaging', + AfricasTalkingSMSMode.PREMIUM: + 'https://content.africastalking.com/version1/messaging', + AfricasTalkingSMSMode.SANDBOX: + 'https://api.sandbox.africastalking.com/version1/messaging', + } + + # The maximum allowable characters allowed in the title per message + title_maxlen = 0 + + # The maximum allowable characters allowed in the body per message + body_maxlen = 160 + + # The maximum amount of phone numbers that can reside within a single + # batch transfer + default_batch_size = 50 + + # Define object templates + templates = ( + '{schema}://{appuser}@{apikey}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'appuser': { + 'name': _('App User Name'), + 'type': 'string', + 'regex': (r'^[A-Z0-9_-]+$', 'i'), + 'required': True, + }, + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[A-Z0-9_-]+$', 'i'), + }, + 'target_phone': { + 'name': _('Target Phone'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'apikey': { + 'alias_of': 'apikey', + }, + 'from': { + # Your registered short code or alphanumeric + 'name': _('From'), + 'type': 'string', + 'default': 'AFRICASTKNG', + 'map_to': 'sender', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + 'mode': { + 'name': _('SMS Mode'), + 'type': 'choice:string', + 'values': AFRICAS_TALKING_SMS_MODES, + 'default': AFRICAS_TALKING_SMS_MODES[0], + }, + }) + + def __init__(self, appuser, apikey, targets=None, sender=None, batch=None, + mode=None, **kwargs): + """ + Initialize Africas Talking Object + """ + super().__init__(**kwargs) + + self.appuser = validate_regex( + appuser, *self.template_tokens['appuser']['regex']) + if not self.appuser: + msg = 'The Africas Talking appuser specified ({}) is invalid.'\ + .format(appuser) + self.logger.warning(msg) + raise TypeError(msg) + + self.apikey = validate_regex( + apikey, *self.template_tokens['apikey']['regex']) + if not self.apikey: + msg = 'The Africas Talking apikey specified ({}) is invalid.'\ + .format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Prepare Sender + self.sender = self.template_args['from']['default'] \ + if sender is None else sender + + # Prepare Batch Mode Flag + self.batch = self.template_args['batch']['default'] \ + if batch is None else batch + + self.mode = self.template_args['mode']['default'] \ + if not isinstance(mode, str) else mode.lower() + + if isinstance(mode, str) and mode: + self.mode = next( + (a for a in AFRICAS_TALKING_SMS_MODES if a.startswith( + mode.lower())), None) + + if self.mode not in AFRICAS_TALKING_SMS_MODES: + msg = 'The Africas Talking mode specified ({}) is invalid.'\ + .format(mode) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.mode = self.template_args['mode']['default'] + + # Parse our targets + self.targets = list() + + for target in parse_phone_no(targets): + # Validate targets and drop bad ones: + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + continue + + # store valid phone number + # Carry forward '+' if defined, otherwise do not... + self.targets.append( + ('+' + result['full']) + if target.lstrip()[0] == '+' else result['full']) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Africas Talking Notification + """ + + if not self.targets: + # There is no one to email; we're done + self.logger.warning( + 'There are no Africas Talking recipients to notify') + return False + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'apiKey': self.apikey, + } + + # error tracking (used for function return) + has_error = False + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + # Create a copy of the target list + for index in range(0, len(self.targets), batch_size): + # Prepare our payload + payload = { + 'username': self.appuser, + 'to': ','.join(self.targets[index:index + batch_size]), + 'from': self.sender, + 'message': body, + } + + # Acquire our URL + notify_url = self.notify_url[self.mode] + + self.logger.debug( + 'Africas Talking POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate)) + self.logger.debug('Africas Talking Payload: %s' % str(payload)) + + # Printable target detail + p_target = self.targets[index] if batch_size == 1 \ + else '{} target(s)'.format( + len(self.targets[index:index + batch_size])) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + notify_url, + data=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + # Sample response + # { + # "SMSMessageData": { + # "Message": "Sent to 1/1 Total Cost: KES 0.8000", + # "Recipients": [{ + # "statusCode": 101, + # "number": "+254711XXXYYY", + # "status": "Success", + # "cost": "KES 0.8000", + # "messageId": "ATPid_SampleTxnId123" + # }] + # } + # } + + if r.status_code not in (100, 101, 102, requests.codes.ok): + # We had a problem + status_str = \ + NotifyAfricasTalking.http_response_code_lookup( + r.status_code, AFRICAS_TALKING_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send Africas Talking notification to {}: ' + '{}{}error={}.'.format( + p_target, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent Africas Talking notification to {}.' + .format(p_target)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Africas Talking ' + 'notification to {}.'.format(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 = { + 'batch': 'yes' if self.batch else 'no', + } + + if self.sender != self.template_args['from']['default']: + # Set our sender if it was set + params['from'] = self.sender + + if self.mode != self.template_args['mode']['default']: + # Set our mode + params['mode'] = self.mode + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{appuser}@{apikey}/{targets}?{params}'.format( + schema=self.secure_protocol, + appuser=NotifyAfricasTalking.quote(self.appuser, safe=''), + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='/'.join( + [NotifyAfricasTalking.quote(x, safe='+') + for x in self.targets]), + params=NotifyAfricasTalking.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + + return targets if targets > 0 else 1 + + @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 + + # The Application User ID + results['appuser'] = NotifyAfricasTalking.unquote(results['user']) + + # Prepare our targets + results['targets'] = [] + + # Our Application APIKey + if 'apikey' in results['qsd'] and len(results['qsd']['apikey']): + # Store our apikey if specified as keyword + results['apikey'] = \ + NotifyAfricasTalking.unquote(results['qsd']['apikey']) + + # This means our host is actually a phone number (target) + results['targets'].append( + NotifyAfricasTalking.unquote(results['host'])) + + else: + # First item is our apikey + results['apikey'] = NotifyAfricasTalking.unquote(results['host']) + + # Store our remaining targets found on path + results['targets'].extend( + NotifyAfricasTalking.split_path(results['fullpath'])) + + # The 'from' makes it easier to use yaml configuration + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['sender'] = \ + NotifyAfricasTalking.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'] += \ + NotifyAfricasTalking.parse_phone_no(results['qsd']['to']) + + # Get our Mode + if 'mode' in results['qsd'] and len(results['qsd']['mode']): + results['mode'] = \ + NotifyAfricasTalking.unquote(results['qsd']['mode']) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get( + 'batch', + NotifyAfricasTalking.template_args['batch']['default'])) + + return results diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index e8c3abb8..339d5c03 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -39,12 +39,12 @@ 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, APRS, AWS SES, AWS SNS, Bark, Boxcar, Burst SMS, BulkSMS, BulkVS, -Chantify, ClickSend, DAPNET, DingTalk, Discord, E-Mail, Emby, FCM, Feishu, -Flock, Free Mobile, Google Chat, Gotify, Growl, Guilded, Home Assistant, -httpSMS, IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, Line, LunaSea, -MacOSX, Mailgun, Mastodon, Mattermost,Matrix, MessageBird, Microsoft Windows, -Microsoft Teams, Misskey, MQTT, MSG91, MyAndroid, Nexmo, Nextcloud, +Africas Talking, Apprise API, APRS, AWS SES, AWS SNS, Bark, Boxcar, Burst SMS, +BulkSMS, BulkVS, Chantify, ClickSend, DAPNET, DingTalk, Discord, E-Mail, Emby, +FCM, Feishu, Flock, Free Mobile, Google Chat, Gotify, Growl, Guilded, Home +Assistant, httpSMS, IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, Line, +LunaSea, MacOSX, Mailgun, Mastodon, Mattermost,Matrix, MessageBird, Microsoft +Windows, Microsoft Teams, Misskey, MQTT, MSG91, MyAndroid, Nexmo, Nextcloud, NextcloudTalk, Notica, Notifiarr, Notifico, ntfy, Office365, OneSignal, Opsgenie, PagerDuty, PagerTree, ParsePlatform, PopcornNotify, Prowl, Pushalot, PushBullet, Pushjet, PushMe, Pushover, PushSafer, Pushy, PushDeer, Revolt, diff --git a/test/test_plugin_africas_talking.py b/test/test_plugin_africas_talking.py new file mode 100644 index 00000000..c8df1869 --- /dev/null +++ b/test/test_plugin_africas_talking.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +from unittest import mock + +import requests +from apprise import Apprise +from apprise.plugins.africas_talking import NotifyAfricasTalking +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 = ( + ('atalk://', { + # Instantiated but no auth, so no notification can happen + 'instance': TypeError, + }), + ('atalk://:@/', { + # invalid auth + 'instance': TypeError + }), + ('atalk://user@^/', { + # invalid apikey + 'instance': TypeError + }), + ('atalk://user@apikey/{}'.format('3' * 5), { + # invalid nubmer provided + 'instance': NotifyAfricasTalking, + # Expected notify() response because we have no one to notify + 'notify_response': False, + }), + ('atalk://user@apikey/123/{}/abcd/+{}'.format( + '3' * 11, '4' * 11), { + # includes a few invalid bits of info + 'instance': NotifyAfricasTalking, + 'privacy_url': 'atalk://user@a...y/33333333333/+44444444444' + }), + ('atalk://user@apikey/+{}?batch=y'.format('4' * 11), { + 'instance': NotifyAfricasTalking, + + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'atalk://user@a...y/+44444444444', + }), + ('atalk://user@apikey/+{}?mode=invalid'.format('4' * 11), { + 'instance': TypeError + }), + ('atalk://user@apikey/+{}?mode=s'.format('4' * 11), { + # S will match the sandbox + 'instance': NotifyAfricasTalking, + }), + ('atalk://user@apikey/+{}?mode=PREM'.format('4' * 11), { + # PREM will match premium (not case sensitive) + 'instance': NotifyAfricasTalking, + }), + ('atalk://{}?apikey=key&user=user&from=FROMUSER'.format('1' * 11), { + # use get args to acomplish the same thing + 'instance': NotifyAfricasTalking, + }), + ('atalk://_?user=user&to={},{}&key={}&from={}'.format( + '1' * 11, '2' * 11, 'b' * 10, '5' * 13), { + # use get args to acomplish the same thing + 'instance': NotifyAfricasTalking, + }), + ('atalk://user@apikey/{}/'.format('1' * 11), { + 'instance': NotifyAfricasTalking, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('atalk://user@apikey/{}/'.format('1' * 11), { + 'instance': NotifyAfricasTalking, + # 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_atalk_urls(): + """ + NotifyTemplate() Apprise URLs + + """ + + # Run our general tests + AppriseURLTester(tests=apprise_url_tests).run_all() + + +@mock.patch('requests.post') +def test_plugin_atalk_edge_cases(mock_post): + """ + NotifyAfricasTalking() Edge Cases + + """ + + # Initialize some generic (but valid) tokens + apikey = 'my-api-key' + appuser = 'my-app-user' + targets = [ + '+1(555) 123-1234', + '1555 5555555', + # A garbage entry + '12', + ] + + # Prepare our response + response = requests.Request() + response.status_code = requests.codes.ok + + # Prepare Mock + mock_post.return_value = response + + # Instantiate our object + obj = Apprise.instantiate( + 'atalk://{}@{}/{}?batch=n'.format(appuser, apikey, '/'.join(targets))) + + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is True + + # We know there are 2 (valid) targets + assert len(obj) == 2 + + # Test our call count + assert mock_post.call_count == 2 + + # Test + details = mock_post.call_args_list[0] + headers = details[1]['headers'] + assert headers['apiKey'] == apikey + payload = details[1]['data'] + assert payload['username'] == appuser + assert payload['from'] == 'AFRICASTKNG' + assert payload['to'] == '+15551231234' + assert payload['message'] == 'title\r\nbody' + + details = mock_post.call_args_list[1] + headers = details[1]['headers'] + assert headers['apiKey'] == apikey + payload = details[1]['data'] + assert payload['username'] == appuser + assert payload['from'] == 'AFRICASTKNG' + assert payload['to'] == '15555555555' + assert payload['message'] == 'title\r\nbody' + + # Verify our URL looks good + assert obj.url().startswith( + 'atalk://{}@{}/{}'.format(appuser, apikey, '/'.join( + ['+15551231234', '15555555555']))) + + assert 'batch=no' in obj.url() + + # Reset our mock object + mock_post.reset_mock() + + # With our batch in place, our calculations are different + # Testing URL restructuring here as well where phone # is found + # in host + obj = Apprise.instantiate( + 'atalk://{}?user={}&apikey={}&batch=y&from=TEST'.format( + '/'.join(targets), appuser, apikey)) + + # 2 phones were loaded but counted as 1 due to batch flag + assert len(obj) == 1 + + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is True + + # Test our call count (batched into 1) + assert mock_post.call_count == 1 + + details = mock_post.call_args_list[0] + headers = details[1]['headers'] + assert headers['apiKey'] == apikey + payload = details[1]['data'] + assert payload['username'] == appuser + assert payload['from'] == 'TEST' + assert payload['to'] == '+15551231234,15555555555' + assert payload['message'] == 'title\r\nbody'