mirror of
https://github.com/caronc/apprise.git
synced 2024-11-21 23:53:23 +01:00
Twilio WhatsApp support added (#1173)
This commit is contained in:
parent
7efb6c5132
commit
1e4b4355ce
@ -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,14 +325,20 @@ 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
|
||||||
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
|
# Some Debug Logging
|
||||||
self.logger.debug('Twilio POST URL: {} (cert_verify={})'.format(
|
self.logger.debug('Twilio POST URL: {} (cert_verify={})'.format(
|
||||||
@ -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…
Reference in New Issue
Block a user