mirror of https://github.com/caronc/apprise
Add twilio phone calls support (#1408)
Co-authored-by: Amanda Hernando <amanda.hernando@geo-satis.com>master
parent
969c8db1d3
commit
3944821a32
|
@ -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/
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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({
|
||||
|
|
Loading…
Reference in New Issue