Add twilio phone calls support (#1408)

Co-authored-by: Amanda Hernando <amanda.hernando@geo-satis.com>
master
Amanda Hernando 2025-09-10 00:38:25 +02:00 committed by GitHub
parent 969c8db1d3
commit 3944821a32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 166 additions and 27 deletions

View File

@ -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<br/>smseagles://hostname:port/@ToContact<br/>smseagles://hostname:port/#ToGroup<br/>smseagles://hostname:port/ToPhoneNo1/#ToGroup/@ToContact/
| [SMS Manager](https://github.com/caronc/apprise/wiki/Notify_sms_manager) | smsmgr:// | (TCP) 443 | smsmgr://ApiKey@ToPhoneNo<br/>smsmgr://ApiKey@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
| [Threema Gateway](https://github.com/caronc/apprise/wiki/Notify_threema) | threema:// | (TCP) 443 | threema://GatewayID@secret/ToPhoneNo<br/>threema://GatewayID@secret/ToEmail<br/>threema://GatewayID@secret/ToThreemaID/<br/>threema://GatewayID@secret/ToEmail/ToThreemaID/ToPhoneNo/...
| [Twilio](https://github.com/caronc/apprise/wiki/Notify_twilio) | twilio:// | (TCP) 443 | twilio://AccountSid:AuthToken@FromPhoneNo<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo?apikey=Key<br/>twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo<br/>twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
| [Twilio](https://github.com/caronc/apprise/wiki/Notify_twilio) | twilio:// | (TCP) 443 | twilio://AccountSid:AuthToken@FromPhoneNo<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo?apikey=Key<br/>twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo<br/>twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo?method=call<br/>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<br/>voipms://password:email/FromPhoneNo/ToPhoneNo<br/>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<br/>nexmo://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo<br/>nexmo://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/

View File

@ -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

View File

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