Twilio WhatsApp support added (#1173)

This commit is contained in:
Chris Caron 2024-07-22 18:23:39 -04:00 committed by GitHub
parent 7efb6c5132
commit 1e4b4355ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 111 additions and 23 deletions

View File

@ -43,6 +43,7 @@
# or consider purchasing a short-code from here:
# https://www.twilio.com/docs/glossary/what-is-a-short-code
#
import re
import requests
from json import loads
@ -55,6 +56,22 @@ from ..utils import validate_regex
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):
"""
A wrapper for Twilio Notifications
@ -117,14 +134,14 @@ class NotifyTwilio(NotifyBase):
'name': _('From Phone No'),
'type': 'string',
'required': True,
'regex': (r'^\+?[0-9\s)(+-]+$', 'i'),
'regex': (r'^([a-z]+:)?\+?[0-9\s)(+-]+$', 'i'),
'map_to': 'source',
},
'target_phone': {
'name': _('Target Phone No'),
'type': 'string',
'prefix': '+',
'regex': (r'^[0-9\s)(+-]+$', 'i'),
'regex': (r'^([a-z]+:)?[0-9\s)(+-]+$', 'i'),
'map_to': 'targets',
},
'short_code': {
@ -190,7 +207,22 @@ class NotifyTwilio(NotifyBase):
self.apikey = validate_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:
msg = 'The Account (From) Phone # or Short-code specified ' \
'({}) is invalid.'.format(source)
@ -220,18 +252,35 @@ class NotifyTwilio(NotifyBase):
# Parse our targets
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:
result = is_phone_no(target)
result = is_phone_no(result.group('phoneno'))
if not result:
self.logger.warning(
'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
# store valid phone number
self.targets.append('+{}'.format(result['full']))
self.targets.append((mode, '+{}'.format(result['full'])))
return
@ -260,9 +309,8 @@ class NotifyTwilio(NotifyBase):
# Prepare our payload
payload = {
'Body': body,
'From': self.source,
# The To gets populated in the loop below
# The From and To gets populated in the loop below
'From': None,
'To': None,
}
@ -277,14 +325,20 @@ class NotifyTwilio(NotifyBase):
if len(targets) == 0:
# No sources specified, use our own phone no
targets.append(self.source)
targets.append((self.default_mode, self.source))
while len(targets):
# Get our target to notify
target = targets.pop(0)
(mode, target) = targets.pop(0)
# Prepare our user
payload['To'] = target
if mode is TwilioMessageMode.TEXT:
payload['From'] = self.source
payload['To'] = target
else: # WhatsApp support (via Twilio)
payload['From'] = f'whatsapp:{self.source}'
payload['To'] = f'whatsapp:{target}'
# Some Debug Logging
self.logger.debug('Twilio POST URL: {} (cert_verify={})'.format(
@ -376,9 +430,13 @@ class NotifyTwilio(NotifyBase):
sid=self.pprint(
self.account_sid, privacy, mode=PrivacyMode.Tail, 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(
[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))
def __len__(self):
@ -442,6 +500,6 @@ class NotifyTwilio(NotifyBase):
# The 'to' makes it easier to use yaml configuration
if 'to' in results['qsd'] and len(results['qsd']['to']):
results['targets'] += \
NotifyTwilio.parse_phone_no(results['qsd']['to'])
NotifyTwilio.parse_phone_no(results['qsd']['to'], prefix=True)
return results

View File

@ -577,7 +577,7 @@ class URLBase:
return content
@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
Parses a specified set of data and breaks it into a list.
@ -600,7 +600,7 @@ class URLBase:
# Nothing further to do
return []
content = parse_phone_no(content)
content = parse_phone_no(content, prefix=prefix)
return content

View File

@ -174,6 +174,11 @@ IS_PHONE_NO = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$')
PHONE_NO_DETECTION_RE = re.compile(
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
# rougly conforms to a ham radio call sign before we parse it further
IS_CALL_SIGN = re.compile(
@ -939,7 +944,7 @@ def parse_bool(arg, default=False):
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
and returns a list.
@ -948,7 +953,8 @@ def parse_phone_no(*args, store_unparseable=True, **kwargs):
result = []
for arg in args:
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:
result += _result
@ -966,7 +972,7 @@ def parse_phone_no(*args, store_unparseable=True, **kwargs):
elif isinstance(arg, (set, list, tuple)):
# Use recursion to handle the list of phone numbers
result += parse_phone_no(
*arg, store_unparseable=store_unparseable)
*arg, store_unparseable=store_unparseable, prefix=prefix)
return result

View File

@ -69,8 +69,8 @@ apprise_url_tests = (
# sid and token provided and from but invalid from no
'instance': TypeError,
}),
('twilio://AC{}:{}@{}/123/{}/abcd/'.format(
'a' * 32, 'b' * 32, '3' * 11, '9' * 15), {
('twilio://AC{}:{}@{}/123/{}/abcd/w:{}'.format(
'a' * 32, 'b' * 32, '3' * 11, '9' * 15, 8 * 11), {
# valid everything but target numbers
'instance': NotifyTwilio,
}),
@ -81,6 +81,20 @@ apprise_url_tests = (
# Our expected url(privacy=True) startswith() response:
'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), {
# using short-code (6 characters)
'instance': NotifyTwilio,
@ -95,6 +109,11 @@ apprise_url_tests = (
# use get args to acomplish the same thing
'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(
'a' * 32, 'b' * 32, '5' * 11), {
# 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(
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
response.status_code = 400
response.content = dumps({