diff --git a/README.md b/README.md index 4967ba49..619866b6 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ SMS Notifications for the most part do not have a both a `title` and `body`. Th | [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/ | [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/ +| [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/
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo?method=call
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN?method=call | [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/ | [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/twilio.py b/apprise/plugins/twilio.py index 5f18f51b..a5d0f87d 100644 --- a/apprise/plugins/twilio.py +++ b/apprise/plugins/twilio.py @@ -29,10 +29,11 @@ # AUTH_TOKEN and ACCOUNT SID right from your console/dashboard at: # https://www.twilio.com/console # -# You will also need to send the SMS From a phone number or account id name. +# You will also need to send the SMS or do the call From a phone number or +# account id name. -# This is identified as the source (or where the SMS message will originate -# from). Activated phone numbers can be found on your dashboard here: +# This is identified as the source (or where the SMS message or the call will +# originate from). Activated phone numbers can be found on your dashboard here: # - https://www.twilio.com/console/phone-numbers/incoming # # Alternatively, you can open your wallet and request a different Twilio @@ -59,6 +60,19 @@ MODE_DETECT_RE = re.compile( ) +class TwilioNotificationMethod: + """Twilio Notification Method.""" + + SMS = "sms" + CALL = "call" + + +TWILIO_NOTIFICATION_METHODS = ( + TwilioNotificationMethod.SMS, + TwilioNotificationMethod.CALL, +) + + class TwilioMessageMode: """Twilio Message Mode.""" @@ -92,13 +106,21 @@ class NotifyTwilio(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = "https://github.com/caronc/apprise/wiki/Notify_twilio" - # Twilio uses the http protocol with JSON requests - notify_url = ( + # Twilio uses the http protocol with JSON message requests + notify_sms_url = ( "https://api.twilio.com/2010-04-01/Accounts/{sid}/Messages.json" ) - # The maximum length of the body - body_maxlen = 160 + # Twilio uses the http protocol with JSON call requests + notify_call_url = ( + "https://api.twilio.com/2010-04-01/Accounts/{sid}/Calls.json" + ) + + # The maximum length of the sms body + body_sms_maxlen = 160 + + # The maximum length of the call body in xml format + body_call_maxlen = 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. @@ -177,6 +199,12 @@ class NotifyTwilio(NotifyBase): "private": True, "regex": (r"^SK[a-f0-9]+$", "i"), }, + "method": { + "name": _("Notification Method: sms or call"), + "type": "choice:string", + "values": TWILIO_NOTIFICATION_METHODS, + "default": TWILIO_NOTIFICATION_METHODS[0], + }, }, ) @@ -187,6 +215,7 @@ class NotifyTwilio(NotifyBase): source, targets=None, apikey=None, + method=None, **kwargs, ): """Initialize Twilio Object.""" @@ -220,6 +249,26 @@ class NotifyTwilio(NotifyBase): apikey, *self.template_args["apikey"]["regex"] ) + # Set notification method + if isinstance(method, str) and method: + self.method = next(( + a + for a in TWILIO_NOTIFICATION_METHODS + if a.startswith(method.lower()) + ), + None, + ) + + if self.method not in TWILIO_NOTIFICATION_METHODS: + msg = ( + f"The Twilio notification method specified ({method}) " + "is invalid." + ) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.method = self.template_args["method"]["default"] + # Detect mode result = MODE_DETECT_RE.match(source) if not result: @@ -238,6 +287,16 @@ class NotifyTwilio(NotifyBase): else TwilioMessageMode.TEXT ) + # Check compatibility between notification method and mode + if self.method == TwilioNotificationMethod.CALL and \ + self.default_mode == TwilioMessageMode.WHATSAPP: + msg = ( + "The notification method Call is not valid along " + "message mode Whatsapp." + ) + self.logger.warning(msg) + raise TypeError(msg) + result = is_phone_no(result.group("phoneno"), min_len=5) if not result: msg = ( @@ -292,14 +351,15 @@ class NotifyTwilio(NotifyBase): ) continue - # We can't send twilio messages using short-codes as our source - if ( - len(self.source) in (5, 6) - and mode is TwilioMessageMode.WHATSAPP - ): + # We can't use WhatsApp using short-codes as our source or + # for phone calls + if ((len(self.source) in (5, 6) + or self.method == TwilioNotificationMethod.CALL) + and mode is TwilioMessageMode.WHATSAPP): self.logger.warning( - "Dropped WhatsApp phone # " - f"({entry}) because source provided was a short-code.", + f"Dropped WhatsApp phone # ({entry}) because source" + " provided is a short-code or because notification" + " method is phone call.", ) continue @@ -327,15 +387,16 @@ class NotifyTwilio(NotifyBase): } # Prepare our payload - payload = { - "Body": body, - # The From and To gets populated in the loop below - "From": None, - "To": None, - } + payload = {} - # Prepare our Twilio URL - url = self.notify_url.format(sid=self.account_sid) + # Prepare our Twilio URL and payload parameter according + # to notification method + if self.method == TwilioNotificationMethod.SMS: + url = self.notify_sms_url.format(sid=self.account_sid) + payload["Body"] = body + else: + url = self.notify_call_url.format(sid=self.account_sid) + payload["Twiml"] = body # Create a copy of the targets list targets = list(self.targets) @@ -343,8 +404,8 @@ class NotifyTwilio(NotifyBase): # Set up our authentication. Prefer the API Key if provided. auth = (self.apikey or self.account_sid, self.auth_token) - if len(targets) == 0: - # No sources specified, use our own phone no + if len(targets) == 0 and self.method != TwilioNotificationMethod.CALL: + # No sources specified, use our own phone only with messages targets.append((self.default_mode, self.source)) while len(targets): @@ -438,6 +499,14 @@ class NotifyTwilio(NotifyBase): return not has_error + @property + def body_maxlen(self): + """The maximum allowable characters allowed in the body per message. + It is dependent on the notification method.""" + return self.body_sms_maxlen \ + if self.method == TwilioNotificationMethod.SMS \ + else self.body_call_maxlen + @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from @@ -458,6 +527,8 @@ class NotifyTwilio(NotifyBase): # Our URL parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) + params["method"] = self.method + if self.apikey is not None: # apikey specified; pass it back on the url params["apikey"] = self.apikey @@ -545,4 +616,9 @@ class NotifyTwilio(NotifyBase): results["qsd"]["to"], prefix=True ) + # Notification method + if "method" in results["qsd"] and len(results["qsd"]["method"]): + # Extract the notification method from an argument + results["method"] = NotifyTwilio.unquote(results["qsd"]["method"]) + return results diff --git a/tests/test_plugin_twilio.py b/tests/test_plugin_twilio.py index a30d3fd3..3458dc85 100644 --- a/tests/test_plugin_twilio.py +++ b/tests/test_plugin_twilio.py @@ -36,7 +36,7 @@ import pytest import requests from apprise import Apprise -from apprise.plugins.twilio import NotifyTwilio +from apprise.plugins.twilio import NotifyTwilio, TwilioNotificationMethod logging.disable(logging.CRITICAL) @@ -142,6 +142,29 @@ apprise_url_tests = ( "instance": NotifyTwilio, }, ), + ( + "twilio://AC{}:{}@{}?method=sms".format("a" * 32, "b" * 32, "5" * 11), + { + # Specify explicitly notification method sms + "instance": NotifyTwilio, + }, + ), + ( + "twilio://AC{}:{}@{}?method=mms".format("a" * 32, "b" * 32, "5" * 11), + { + # Invalid notification method + "instance": TypeError, + }, + ), + ( + "twilio://AC{}:{}@{}?method=call".format( + "a" * 32, "b" * 32, "w:" + "5" * 11 + ), + { + # Incompatibility between Whatsapp mode and CALL method + "instance": TypeError, + }, + ), ( "twilio://_?sid=AC{}&token={}&from={}".format( "a" * 32, "b" * 32, "5" * 11 @@ -179,6 +202,15 @@ apprise_url_tests = ( "instance": NotifyTwilio, }, ), + ( + "twilio://_?sid=AC{}&token={}&from={}&to={}method=call".format( + "a" * 32, "b" * 32, "5" * 11, "7" * 13 + ), + { + # Specify notification method call + "instance": NotifyTwilio, + }, + ), ( "twilio://AC{}:{}@{}".format("a" * 32, "b" * 32, "6" * 11), { @@ -250,10 +282,21 @@ def test_plugin_twilio_auth(mock_post): # Send Notification assert obj.send(body=message_contents) is True + # Variation of initialization with method call + obj = Apprise.instantiate( + f"twilio://{account_sid}:{auth_token}@{source}/{dest}?method=call" + ) + assert isinstance(obj, NotifyTwilio) + assert isinstance(obj.url(), str) + + # Send Notification + assert obj.send(body=message_contents) is True + # Validate expected call parameters - assert mock_post.call_count == 2 + assert mock_post.call_count == 3 first_call = mock_post.call_args_list[0] second_call = mock_post.call_args_list[1] + third_call = mock_post.call_args_list[2] # URL and message parameters are the same for both calls assert ( @@ -267,20 +310,32 @@ def test_plugin_twilio_auth(mock_post): == second_call[1]["data"]["Body"] == message_contents ) + assert ( + third_call[0][0] + == ("https://api.twilio.com/2010-04-01/Accounts" + f"/{account_sid}/Calls.json") + ) + assert ( + third_call[1]["data"]["Twiml"] + == message_contents + ) assert ( first_call[1]["data"]["From"] == second_call[1]["data"]["From"] + == third_call[1]["data"]["From"] == "+15551233456" ) assert ( first_call[1]["data"]["To"] == second_call[1]["data"]["To"] + == third_call[1]["data"]["To"] == "+15559876543" ) # Auth differs depending on if API Key is used assert first_call[1]["auth"] == (account_sid, auth_token) assert second_call[1]["auth"] == (apikey, auth_token) + assert third_call[1]["auth"] == (account_sid, auth_token) @mock.patch("requests.post") @@ -298,6 +353,7 @@ def test_plugin_twilio_edge_cases(mock_post): account_sid = "AC{}".format("b" * 32) auth_token = "{}".format("b" * 32) source = "+1 (555) 123-3456" + whatsapp_source = "w:" + "+1 (555) 123-3456" # No account_sid specified with pytest.raises(TypeError): @@ -311,6 +367,13 @@ def test_plugin_twilio_edge_cases(mock_post): with pytest.raises(TypeError): NotifyTwilio(account_sid=account_sid, auth_token=auth_token, source="") + # Incompatibility between mode and method + with pytest.raises(TypeError): + NotifyTwilio( + account_sid=account_sid, auth_token=auth_token, + source=whatsapp_source, method=TwilioNotificationMethod.CALL + ) + # a error response response.status_code = 400 response.content = dumps({