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({