Twilio WhatsApp support added (#1173)

pull/1176/head
Chris Caron 4 months ago committed by GitHub
parent 7efb6c5132
commit 1e4b4355ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -43,6 +43,7 @@
# or consider purchasing a short-code from here: # or consider purchasing a short-code from here:
# https://www.twilio.com/docs/glossary/what-is-a-short-code # https://www.twilio.com/docs/glossary/what-is-a-short-code
# #
import re
import requests import requests
from json import loads from json import loads
@ -55,6 +56,22 @@ from ..utils import validate_regex
from ..locale import gettext_lazy as _ from ..locale import gettext_lazy as _
# Twilio Mode Detection
MODE_DETECT_RE = re.compile(
r'\s*((?P<mode>[^:]+)\s*:\s*)?(?P<phoneno>.+)$', re.I)
class TwilioMessageMode:
"""
Twilio Message Mode
"""
# SMS/MMS
TEXT = 'T'
# via WhatsApp
WHATSAPP = 'W'
class NotifyTwilio(NotifyBase): class NotifyTwilio(NotifyBase):
""" """
A wrapper for Twilio Notifications A wrapper for Twilio Notifications
@ -117,14 +134,14 @@ class NotifyTwilio(NotifyBase):
'name': _('From Phone No'), 'name': _('From Phone No'),
'type': 'string', 'type': 'string',
'required': True, 'required': True,
'regex': (r'^\+?[0-9\s)(+-]+$', 'i'), 'regex': (r'^([a-z]+:)?\+?[0-9\s)(+-]+$', 'i'),
'map_to': 'source', 'map_to': 'source',
}, },
'target_phone': { 'target_phone': {
'name': _('Target Phone No'), 'name': _('Target Phone No'),
'type': 'string', 'type': 'string',
'prefix': '+', 'prefix': '+',
'regex': (r'^[0-9\s)(+-]+$', 'i'), 'regex': (r'^([a-z]+:)?[0-9\s)(+-]+$', 'i'),
'map_to': 'targets', 'map_to': 'targets',
}, },
'short_code': { 'short_code': {
@ -190,7 +207,22 @@ class NotifyTwilio(NotifyBase):
self.apikey = validate_regex( self.apikey = validate_regex(
apikey, *self.template_args['apikey']['regex']) apikey, *self.template_args['apikey']['regex'])
result = is_phone_no(source, min_len=5) # Detect mode
result = MODE_DETECT_RE.match(source)
if not result:
msg = 'The Account (From) Phone # or Short-code specified ' \
'({}) is invalid.'.format(source)
self.logger.warning(msg)
raise TypeError(msg)
# prepare our default mode to use for all numbers that follow in
# target definitions
self.default_mode = TwilioMessageMode.WHATSAPP \
if result.group('mode') and \
result.group('mode')[0].lower() == 'w' \
else TwilioMessageMode.TEXT
result = is_phone_no(result.group('phoneno'), min_len=5)
if not result: if not result:
msg = 'The Account (From) Phone # or Short-code specified ' \ msg = 'The Account (From) Phone # or Short-code specified ' \
'({}) is invalid.'.format(source) '({}) is invalid.'.format(source)
@ -220,18 +252,35 @@ class NotifyTwilio(NotifyBase):
# Parse our targets # Parse our targets
self.targets = list() self.targets = list()
for target in parse_phone_no(targets): for entry in parse_phone_no(targets, prefix=True):
# Detect mode
# w: (or whatsapp:) will trigger whatsapp message otherwise
# sms/mms as normal
result = MODE_DETECT_RE.match(entry)
mode = TwilioMessageMode.WHATSAPP if result.group('mode') and \
result.group('mode')[0].lower() == 'w' else self.default_mode
# Validate targets and drop bad ones: # Validate targets and drop bad ones:
result = is_phone_no(target) result = is_phone_no(result.group('phoneno'))
if not result: if not result:
self.logger.warning( self.logger.warning(
'Dropped invalid phone # ' 'Dropped invalid phone # '
'({}) specified.'.format(target), '({}) specified.'.format(entry),
)
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:
self.logger.warning(
'Dropped WhatsApp phone # '
'({}) because source provided was a short-code.'.format(
entry),
) )
continue continue
# store valid phone number # store valid phone number
self.targets.append('+{}'.format(result['full'])) self.targets.append((mode, '+{}'.format(result['full'])))
return return
@ -260,9 +309,8 @@ class NotifyTwilio(NotifyBase):
# Prepare our payload # Prepare our payload
payload = { payload = {
'Body': body, 'Body': body,
'From': self.source, # The From and To gets populated in the loop below
'From': None,
# The To gets populated in the loop below
'To': None, 'To': None,
} }
@ -277,15 +325,21 @@ class NotifyTwilio(NotifyBase):
if len(targets) == 0: if len(targets) == 0:
# No sources specified, use our own phone no # No sources specified, use our own phone no
targets.append(self.source) targets.append((self.default_mode, self.source))
while len(targets): while len(targets):
# Get our target to notify # Get our target to notify
target = targets.pop(0) (mode, target) = targets.pop(0)
# Prepare our user # Prepare our user
if mode is TwilioMessageMode.TEXT:
payload['From'] = self.source
payload['To'] = target payload['To'] = target
else: # WhatsApp support (via Twilio)
payload['From'] = f'whatsapp:{self.source}'
payload['To'] = f'whatsapp:{target}'
# Some Debug Logging # Some Debug Logging
self.logger.debug('Twilio POST URL: {} (cert_verify={})'.format( self.logger.debug('Twilio POST URL: {} (cert_verify={})'.format(
url, self.verify_certificate)) url, self.verify_certificate))
@ -376,9 +430,13 @@ class NotifyTwilio(NotifyBase):
sid=self.pprint( sid=self.pprint(
self.account_sid, privacy, mode=PrivacyMode.Tail, safe=''), self.account_sid, privacy, mode=PrivacyMode.Tail, safe=''),
token=self.pprint(self.auth_token, privacy, safe=''), token=self.pprint(self.auth_token, privacy, safe=''),
source=NotifyTwilio.quote(self.source, safe=''), source=NotifyTwilio.quote(
self.source if self.default_mode is TwilioMessageMode.TEXT
else 'w:{}'.format(self.source), safe=''),
targets='/'.join( targets='/'.join(
[NotifyTwilio.quote(x, safe='') for x in self.targets]), [NotifyTwilio.quote(
x[1] if x[0] is TwilioMessageMode.TEXT
else 'w:{}'.format(x[1]), safe='') for x in self.targets]),
params=NotifyTwilio.urlencode(params)) params=NotifyTwilio.urlencode(params))
def __len__(self): def __len__(self):
@ -442,6 +500,6 @@ class NotifyTwilio(NotifyBase):
# The 'to' makes it easier to use yaml configuration # The 'to' makes it easier to use yaml configuration
if 'to' in results['qsd'] and len(results['qsd']['to']): if 'to' in results['qsd'] and len(results['qsd']['to']):
results['targets'] += \ results['targets'] += \
NotifyTwilio.parse_phone_no(results['qsd']['to']) NotifyTwilio.parse_phone_no(results['qsd']['to'], prefix=True)
return results return results

@ -577,7 +577,7 @@ class URLBase:
return content return content
@staticmethod @staticmethod
def parse_phone_no(content, unquote=True): def parse_phone_no(content, unquote=True, prefix=False):
"""A wrapper to utils.parse_phone_no() with unquoting support """A wrapper to utils.parse_phone_no() with unquoting support
Parses a specified set of data and breaks it into a list. Parses a specified set of data and breaks it into a list.
@ -600,7 +600,7 @@ class URLBase:
# Nothing further to do # Nothing further to do
return [] return []
content = parse_phone_no(content) content = parse_phone_no(content, prefix=prefix)
return content return content

@ -174,6 +174,11 @@ IS_PHONE_NO = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$')
PHONE_NO_DETECTION_RE = re.compile( PHONE_NO_DETECTION_RE = re.compile(
r'\s*([+(\s]*[0-9][0-9()\s-]+[0-9])(?=$|[\s,+(]+[0-9])', re.I) r'\s*([+(\s]*[0-9][0-9()\s-]+[0-9])(?=$|[\s,+(]+[0-9])', re.I)
# Support for prefix: (string followed by colon) infront of phone no
PHONE_NO_WPREFIX_DETECTION_RE = re.compile(
r'\s*((?:[a-z]+:)?[+(\s]*[0-9][0-9()\s-]+[0-9])'
r'(?=$|(?:[a-z]+:)?[\s,+(]+[0-9])', re.I)
# A simple verification check to make sure the content specified # A simple verification check to make sure the content specified
# rougly conforms to a ham radio call sign before we parse it further # rougly conforms to a ham radio call sign before we parse it further
IS_CALL_SIGN = re.compile( IS_CALL_SIGN = re.compile(
@ -939,7 +944,7 @@ def parse_bool(arg, default=False):
return bool(arg) return bool(arg)
def parse_phone_no(*args, store_unparseable=True, **kwargs): def parse_phone_no(*args, store_unparseable=True, prefix=False, **kwargs):
""" """
Takes a string containing phone numbers separated by comma's and/or spaces Takes a string containing phone numbers separated by comma's and/or spaces
and returns a list. and returns a list.
@ -948,7 +953,8 @@ def parse_phone_no(*args, store_unparseable=True, **kwargs):
result = [] result = []
for arg in args: for arg in args:
if isinstance(arg, str) and arg: if isinstance(arg, str) and arg:
_result = PHONE_NO_DETECTION_RE.findall(arg) _result = (PHONE_NO_DETECTION_RE if not prefix
else PHONE_NO_WPREFIX_DETECTION_RE).findall(arg)
if _result: if _result:
result += _result result += _result
@ -966,7 +972,7 @@ def parse_phone_no(*args, store_unparseable=True, **kwargs):
elif isinstance(arg, (set, list, tuple)): elif isinstance(arg, (set, list, tuple)):
# Use recursion to handle the list of phone numbers # Use recursion to handle the list of phone numbers
result += parse_phone_no( result += parse_phone_no(
*arg, store_unparseable=store_unparseable) *arg, store_unparseable=store_unparseable, prefix=prefix)
return result return result

@ -69,8 +69,8 @@ apprise_url_tests = (
# sid and token provided and from but invalid from no # sid and token provided and from but invalid from no
'instance': TypeError, 'instance': TypeError,
}), }),
('twilio://AC{}:{}@{}/123/{}/abcd/'.format( ('twilio://AC{}:{}@{}/123/{}/abcd/w:{}'.format(
'a' * 32, 'b' * 32, '3' * 11, '9' * 15), { 'a' * 32, 'b' * 32, '3' * 11, '9' * 15, 8 * 11), {
# valid everything but target numbers # valid everything but target numbers
'instance': NotifyTwilio, 'instance': NotifyTwilio,
}), }),
@ -81,6 +81,20 @@ apprise_url_tests = (
# Our expected url(privacy=True) startswith() response: # Our expected url(privacy=True) startswith() response:
'privacy_url': 'twilio://...aaaa:b...b@12345', 'privacy_url': 'twilio://...aaaa:b...b@12345',
}), }),
('twilio://AC{}:{}@98765/{}/w:{}/'.format(
'a' * 32, 'b' * 32, '4' * 11, '5' * 11), {
# using short-code (5 characters) and 1 twillio address ignored
# because source phone number can not be a short code
'instance': NotifyTwilio,
# Our expected url(privacy=True) startswith() response:
'privacy_url': 'twilio://...aaaa:b...b@98765',
}),
('twilio://AC{}:{}@w:12345/{}/{}'.format(
'a' * 32, 'b' * 32, '4' * 11, '5' * 11), {
# Invalid short-code
'instance': TypeError,
}),
('twilio://AC{}:{}@123456/{}'.format('a' * 32, 'b' * 32, '4' * 11), { ('twilio://AC{}:{}@123456/{}'.format('a' * 32, 'b' * 32, '4' * 11), {
# using short-code (6 characters) # using short-code (6 characters)
'instance': NotifyTwilio, 'instance': NotifyTwilio,
@ -95,6 +109,11 @@ apprise_url_tests = (
# use get args to acomplish the same thing # use get args to acomplish the same thing
'instance': NotifyTwilio, 'instance': NotifyTwilio,
}), }),
('twilio://_?sid=AC{}&token={}&from={}&to=w:{}'.format(
'a' * 32, 'b' * 32, '5' * 11, '6' * 11), {
# Support whatsapp (w: before number)
'instance': NotifyTwilio,
}),
('twilio://_?sid=AC{}&token={}&source={}'.format( ('twilio://_?sid=AC{}&token={}&source={}'.format(
'a' * 32, 'b' * 32, '5' * 11), { 'a' * 32, 'b' * 32, '5' * 11), {
# use get args to acomplish the same thing (use source instead of from) # use get args to acomplish the same thing (use source instead of from)
@ -228,6 +247,11 @@ def test_plugin_twilio_edge_cases(mock_post):
NotifyTwilio( NotifyTwilio(
account_sid=account_sid, auth_token=None, source=source) account_sid=account_sid, auth_token=None, source=source)
# Source is bad
with pytest.raises(TypeError):
NotifyTwilio(
account_sid=account_sid, auth_token=auth_token, source='')
# a error response # a error response
response.status_code = 400 response.status_code = 400
response.content = dumps({ response.content = dumps({

Loading…
Cancel
Save