1# -*- coding: utf-8 -*- 2# 3# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com> 4# All rights reserved. 5# 6# This code is licensed under the MIT License. 7# 8# Permission is hereby granted, free of charge, to any person obtaining a copy 9# of this software and associated documentation files(the "Software"), to deal 10# in the Software without restriction, including without limitation the rights 11# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell 12# copies of the Software, and to permit persons to whom the Software is 13# furnished to do so, subject to the following conditions : 14# 15# The above copyright notice and this permission notice shall be included in 16# all copies or substantial portions of the Software. 17# 18# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE 21# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24# THE SOFTWARE. 25 26# To use this service you will need a Twilio account to which you can get your 27# AUTH_TOKEN and ACCOUNT SID right from your console/dashboard at: 28# https://www.twilio.com/console 29# 30# You will also need to send the SMS From a phone number or account id name. 31 32# This is identified as the source (or where the SMS message will originate 33# from). Activated phone numbers can be found on your dashboard here: 34# - https://www.twilio.com/console/phone-numbers/incoming 35# 36# Alternatively, you can open your wallet and request a different Twilio 37# phone # from: 38# https://www.twilio.com/console/phone-numbers/search 39# 40# or consider purchasing a short-code from here: 41# https://www.twilio.com/docs/glossary/what-is-a-short-code 42# 43import requests 44from json import loads 45 46from .NotifyBase import NotifyBase 47from ..URLBase import PrivacyMode 48from ..common import NotifyType 49from ..utils import is_phone_no 50from ..utils import parse_phone_no 51from ..utils import validate_regex 52from ..AppriseLocale import gettext_lazy as _ 53 54 55class NotifyTwilio(NotifyBase): 56 """ 57 A wrapper for Twilio Notifications 58 """ 59 60 # The default descriptive name associated with the Notification 61 service_name = 'Twilio' 62 63 # The services URL 64 service_url = 'https://www.twilio.com/' 65 66 # All notification requests are secure 67 secure_protocol = 'twilio' 68 69 # Allow 300 requests per minute. 70 # 60/300 = 0.2 71 request_rate_per_sec = 0.20 72 73 # the number of seconds undelivered messages should linger for 74 # in the Twilio queue 75 validity_period = 14400 76 77 # A URL that takes you to the setup/help of the specific protocol 78 setup_url = 'https://github.com/caronc/apprise/wiki/Notify_twilio' 79 80 # Twilio uses the http protocol with JSON requests 81 notify_url = 'https://api.twilio.com/2010-04-01/Accounts/' \ 82 '{sid}/Messages.json' 83 84 # The maximum length of the body 85 body_maxlen = 160 86 87 # A title can not be used for SMS Messages. Setting this to zero will 88 # cause any title (if defined) to get placed into the message body. 89 title_maxlen = 0 90 91 # Define object templates 92 templates = ( 93 '{schema}://{account_sid}:{auth_token}@{from_phone}', 94 '{schema}://{account_sid}:{auth_token}@{from_phone}/{targets}', 95 ) 96 97 # Define our template tokens 98 template_tokens = dict(NotifyBase.template_tokens, **{ 99 'account_sid': { 100 'name': _('Account SID'), 101 'type': 'string', 102 'private': True, 103 'required': True, 104 'regex': (r'^AC[a-f0-9]+$', 'i'), 105 }, 106 'auth_token': { 107 'name': _('Auth Token'), 108 'type': 'string', 109 'private': True, 110 'required': True, 111 'regex': (r'^[a-z0-9]+$', 'i'), 112 }, 113 'from_phone': { 114 'name': _('From Phone No'), 115 'type': 'string', 116 'required': True, 117 'regex': (r'^\+?[0-9\s)(+-]+$', 'i'), 118 'map_to': 'source', 119 }, 120 'target_phone': { 121 'name': _('Target Phone No'), 122 'type': 'string', 123 'prefix': '+', 124 'regex': (r'^[0-9\s)(+-]+$', 'i'), 125 'map_to': 'targets', 126 }, 127 'short_code': { 128 'name': _('Target Short Code'), 129 'type': 'string', 130 'regex': (r'^[0-9]{5,6}$', 'i'), 131 'map_to': 'targets', 132 }, 133 'targets': { 134 'name': _('Targets'), 135 'type': 'list:string', 136 }, 137 }) 138 139 # Define our template arguments 140 template_args = dict(NotifyBase.template_args, **{ 141 'to': { 142 'alias_of': 'targets', 143 }, 144 'from': { 145 'alias_of': 'from_phone', 146 }, 147 'sid': { 148 'alias_of': 'account_sid', 149 }, 150 'token': { 151 'alias_of': 'auth_token', 152 }, 153 'apikey': { 154 'name': _('API Key'), 155 'type': 'string', 156 'private': True, 157 'regex': (r'^SK[a-f0-9]+$', 'i'), 158 }, 159 }) 160 161 def __init__(self, account_sid, auth_token, source, targets=None, 162 apikey=None, ** kwargs): 163 """ 164 Initialize Twilio Object 165 """ 166 super(NotifyTwilio, self).__init__(**kwargs) 167 168 # The Account SID associated with the account 169 self.account_sid = validate_regex( 170 account_sid, *self.template_tokens['account_sid']['regex']) 171 if not self.account_sid: 172 msg = 'An invalid Twilio Account SID ' \ 173 '({}) was specified.'.format(account_sid) 174 self.logger.warning(msg) 175 raise TypeError(msg) 176 177 # The Authentication Token associated with the account 178 self.auth_token = validate_regex( 179 auth_token, *self.template_tokens['auth_token']['regex']) 180 if not self.auth_token: 181 msg = 'An invalid Twilio Authentication Token ' \ 182 '({}) was specified.'.format(auth_token) 183 self.logger.warning(msg) 184 raise TypeError(msg) 185 186 # The API Key associated with the account (optional) 187 self.apikey = validate_regex( 188 apikey, *self.template_args['apikey']['regex']) 189 190 result = is_phone_no(source, min_len=5) 191 if not result: 192 msg = 'The Account (From) Phone # or Short-code specified ' \ 193 '({}) is invalid.'.format(source) 194 self.logger.warning(msg) 195 raise TypeError(msg) 196 197 # Store The Source Phone # and/or short-code 198 self.source = result['full'] 199 200 if len(self.source) < 11 or len(self.source) > 14: 201 # https://www.twilio.com/docs/glossary/what-is-a-short-code 202 # A short code is a special 5 or 6 digit telephone number 203 # that's shorter than a full phone number. 204 if len(self.source) not in (5, 6): 205 msg = 'The Account (From) Phone # specified ' \ 206 '({}) is invalid.'.format(source) 207 self.logger.warning(msg) 208 raise TypeError(msg) 209 210 # else... it as a short code so we're okay 211 212 else: 213 # We're dealing with a phone number; so we need to just 214 # place a plus symbol at the end of it 215 self.source = '+{}'.format(self.source) 216 217 # Parse our targets 218 self.targets = list() 219 220 for target in parse_phone_no(targets): 221 # Validate targets and drop bad ones: 222 result = is_phone_no(target) 223 if not result: 224 self.logger.warning( 225 'Dropped invalid phone # ' 226 '({}) specified.'.format(target), 227 ) 228 continue 229 230 # store valid phone number 231 self.targets.append('+{}'.format(result['full'])) 232 233 return 234 235 def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): 236 """ 237 Perform Twilio Notification 238 """ 239 240 if not self.targets: 241 if len(self.source) in (5, 6): 242 # Generate a warning since we're a short-code. We need 243 # a number to message at minimum 244 self.logger.warning( 245 'There are no valid Twilio targets to notify.') 246 return False 247 248 # error tracking (used for function return) 249 has_error = False 250 251 # Prepare our headers 252 headers = { 253 'User-Agent': self.app_id, 254 'Accept': 'application/json', 255 } 256 257 # Prepare our payload 258 payload = { 259 'Body': body, 260 'From': self.source, 261 262 # The To gets populated in the loop below 263 'To': None, 264 } 265 266 # Prepare our Twilio URL 267 url = self.notify_url.format(sid=self.account_sid) 268 269 # Create a copy of the targets list 270 targets = list(self.targets) 271 272 # Set up our authentication. Prefer the API Key if provided. 273 auth = (self.apikey or self.account_sid, self.auth_token) 274 275 if len(targets) == 0: 276 # No sources specified, use our own phone no 277 targets.append(self.source) 278 279 while len(targets): 280 # Get our target to notify 281 target = targets.pop(0) 282 283 # Prepare our user 284 payload['To'] = target 285 286 # Some Debug Logging 287 self.logger.debug('Twilio POST URL: {} (cert_verify={})'.format( 288 url, self.verify_certificate)) 289 self.logger.debug('Twilio Payload: {}' .format(payload)) 290 291 # Always call throttle before any remote server i/o is made 292 self.throttle() 293 try: 294 r = requests.post( 295 url, 296 auth=auth, 297 data=payload, 298 headers=headers, 299 verify=self.verify_certificate, 300 timeout=self.request_timeout, 301 ) 302 303 if r.status_code not in ( 304 requests.codes.created, requests.codes.ok): 305 # We had a problem 306 status_str = \ 307 NotifyBase.http_response_code_lookup(r.status_code) 308 309 # set up our status code to use 310 status_code = r.status_code 311 312 try: 313 # Update our status response if we can 314 json_response = loads(r.content) 315 status_code = json_response.get('code', status_code) 316 status_str = json_response.get('message', status_str) 317 318 except (AttributeError, TypeError, ValueError): 319 # ValueError = r.content is Unparsable 320 # TypeError = r.content is None 321 # AttributeError = r is None 322 323 # We could not parse JSON response. 324 # We will just use the status we already have. 325 pass 326 327 self.logger.warning( 328 'Failed to send Twilio notification to {}: ' 329 '{}{}error={}.'.format( 330 target, 331 status_str, 332 ', ' if status_str else '', 333 status_code)) 334 335 self.logger.debug( 336 'Response Details:\r\n{}'.format(r.content)) 337 338 # Mark our failure 339 has_error = True 340 continue 341 342 else: 343 self.logger.info( 344 'Sent Twilio notification to {}.'.format(target)) 345 346 except requests.RequestException as e: 347 self.logger.warning( 348 'A Connection error occurred sending Twilio:%s ' % ( 349 target) + 'notification.' 350 ) 351 self.logger.debug('Socket Exception: %s' % str(e)) 352 353 # Mark our failure 354 has_error = True 355 continue 356 357 return not has_error 358 359 def url(self, privacy=False, *args, **kwargs): 360 """ 361 Returns the URL built dynamically based on specified arguments. 362 """ 363 364 # Our URL parameters 365 params = self.url_parameters(privacy=privacy, *args, **kwargs) 366 367 if self.apikey is not None: 368 # apikey specified; pass it back on the url 369 params['apikey'] = self.apikey 370 371 return '{schema}://{sid}:{token}@{source}/{targets}/?{params}'.format( 372 schema=self.secure_protocol, 373 sid=self.pprint( 374 self.account_sid, privacy, mode=PrivacyMode.Tail, safe=''), 375 token=self.pprint(self.auth_token, privacy, safe=''), 376 source=NotifyTwilio.quote(self.source, safe=''), 377 targets='/'.join( 378 [NotifyTwilio.quote(x, safe='') for x in self.targets]), 379 params=NotifyTwilio.urlencode(params)) 380 381 @staticmethod 382 def parse_url(url): 383 """ 384 Parses the URL and returns enough arguments that can allow 385 us to re-instantiate this object. 386 387 """ 388 results = NotifyBase.parse_url(url, verify_host=False) 389 390 if not results: 391 # We're done early as we couldn't load the results 392 return results 393 394 # Get our entries; split_path() looks after unquoting content for us 395 # by default 396 results['targets'] = NotifyTwilio.split_path(results['fullpath']) 397 398 # The hostname is our source number 399 results['source'] = NotifyTwilio.unquote(results['host']) 400 401 # Get our account_side and auth_token from the user/pass config 402 results['account_sid'] = NotifyTwilio.unquote(results['user']) 403 results['auth_token'] = NotifyTwilio.unquote(results['password']) 404 405 # Auth Token 406 if 'token' in results['qsd'] and len(results['qsd']['token']): 407 # Extract the account sid from an argument 408 results['auth_token'] = \ 409 NotifyTwilio.unquote(results['qsd']['token']) 410 411 # Account SID 412 if 'sid' in results['qsd'] and len(results['qsd']['sid']): 413 # Extract the account sid from an argument 414 results['account_sid'] = \ 415 NotifyTwilio.unquote(results['qsd']['sid']) 416 417 # API Key 418 if 'apikey' in results['qsd'] and len(results['qsd']['apikey']): 419 results['apikey'] = results['qsd']['apikey'] 420 421 # Support the 'from' and 'source' variable so that we can support 422 # targets this way too. 423 # The 'from' makes it easier to use yaml configuration 424 if 'from' in results['qsd'] and len(results['qsd']['from']): 425 results['source'] = \ 426 NotifyTwilio.unquote(results['qsd']['from']) 427 if 'source' in results['qsd'] and len(results['qsd']['source']): 428 results['source'] = \ 429 NotifyTwilio.unquote(results['qsd']['source']) 430 431 # Support the 'to' variable so that we can support targets this way too 432 # The 'to' makes it easier to use yaml configuration 433 if 'to' in results['qsd'] and len(results['qsd']['to']): 434 results['targets'] += \ 435 NotifyTwilio.parse_phone_no(results['qsd']['to']) 436 437 return results 438