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# See https://developer.twitter.com/en/docs/direct-messages/\ 27# sending-and-receiving/api-reference/new-event.html 28import re 29import six 30import requests 31from datetime import datetime 32from requests_oauthlib import OAuth1 33from json import dumps 34from json import loads 35from .NotifyBase import NotifyBase 36from ..URLBase import PrivacyMode 37from ..common import NotifyType 38from ..utils import parse_list 39from ..utils import parse_bool 40from ..utils import validate_regex 41from ..AppriseLocale import gettext_lazy as _ 42 43IS_USER = re.compile(r'^\s*@?(?P<user>[A-Z0-9_]+)$', re.I) 44 45 46class TwitterMessageMode(object): 47 """ 48 Twitter Message Mode 49 """ 50 # DM (a Direct Message) 51 DM = 'dm' 52 53 # A Public Tweet 54 TWEET = 'tweet' 55 56 57# Define the types in a list for validation purposes 58TWITTER_MESSAGE_MODES = ( 59 TwitterMessageMode.DM, 60 TwitterMessageMode.TWEET, 61) 62 63 64class NotifyTwitter(NotifyBase): 65 """ 66 A wrapper to Twitter Notifications 67 68 """ 69 70 # The default descriptive name associated with the Notification 71 service_name = 'Twitter' 72 73 # The services URL 74 service_url = 'https://twitter.com/' 75 76 # The default secure protocol is twitter. 77 secure_protocol = 'twitter' 78 79 # A URL that takes you to the setup/help of the specific protocol 80 setup_url = 'https://github.com/caronc/apprise/wiki/Notify_twitter' 81 82 # Do not set body_maxlen as it is set in a property value below 83 # since the length varies depending if we are doing a direct message 84 # or a tweet 85 # body_maxlen = see below @propery defined 86 87 # Twitter does have titles when creating a message 88 title_maxlen = 0 89 90 # Twitter API 91 twitter_api = 'api.twitter.com' 92 93 # Twitter API Reference To Acquire Someone's Twitter ID 94 twitter_lookup = 'https://api.twitter.com/1.1/users/lookup.json' 95 96 # Twitter API Reference To Acquire Current Users Information 97 twitter_whoami = \ 98 'https://api.twitter.com/1.1/account/verify_credentials.json' 99 100 # Twitter API Reference To Send A Private DM 101 twitter_dm = 'https://api.twitter.com/1.1/direct_messages/events/new.json' 102 103 # Twitter API Reference To Send A Public Tweet 104 twitter_tweet = 'https://api.twitter.com/1.1/statuses/update.json' 105 106 # Twitter is kind enough to return how many more requests we're allowed to 107 # continue to make within it's header response as: 108 # X-Rate-Limit-Reset: The epoc time (in seconds) we can expect our 109 # rate-limit to be reset. 110 # X-Rate-Limit-Remaining: an integer identifying how many requests we're 111 # still allow to make. 112 request_rate_per_sec = 0 113 114 # For Tracking Purposes 115 ratelimit_reset = datetime.utcnow() 116 117 # Default to 1000; users can send up to 1000 DM's and 2400 tweets a day 118 # This value only get's adjusted if the server sets it that way 119 ratelimit_remaining = 1 120 121 templates = ( 122 '{schema}://{ckey}/{csecret}/{akey}/{asecret}/{targets}', 123 ) 124 125 # Define our template tokens 126 template_tokens = dict(NotifyBase.template_tokens, **{ 127 'ckey': { 128 'name': _('Consumer Key'), 129 'type': 'string', 130 'private': True, 131 'required': True, 132 }, 133 'csecret': { 134 'name': _('Consumer Secret'), 135 'type': 'string', 136 'private': True, 137 'required': True, 138 }, 139 'akey': { 140 'name': _('Access Key'), 141 'type': 'string', 142 'private': True, 143 'required': True, 144 }, 145 'asecret': { 146 'name': _('Access Secret'), 147 'type': 'string', 148 'private': True, 149 'required': True, 150 }, 151 'target_user': { 152 'name': _('Target User'), 153 'type': 'string', 154 'prefix': '@', 155 'map_to': 'targets', 156 }, 157 'targets': { 158 'name': _('Targets'), 159 'type': 'list:string', 160 }, 161 }) 162 163 # Define our template arguments 164 template_args = dict(NotifyBase.template_args, **{ 165 'mode': { 166 'name': _('Message Mode'), 167 'type': 'choice:string', 168 'values': TWITTER_MESSAGE_MODES, 169 'default': TwitterMessageMode.DM, 170 }, 171 'cache': { 172 'name': _('Cache Results'), 173 'type': 'bool', 174 'default': True, 175 }, 176 'to': { 177 'alias_of': 'targets', 178 }, 179 }) 180 181 def __init__(self, ckey, csecret, akey, asecret, targets=None, 182 mode=TwitterMessageMode.DM, cache=True, **kwargs): 183 """ 184 Initialize Twitter Object 185 186 """ 187 super(NotifyTwitter, self).__init__(**kwargs) 188 189 self.ckey = validate_regex(ckey) 190 if not self.ckey: 191 msg = 'An invalid Twitter Consumer Key was specified.' 192 self.logger.warning(msg) 193 raise TypeError(msg) 194 195 self.csecret = validate_regex(csecret) 196 if not self.csecret: 197 msg = 'An invalid Twitter Consumer Secret was specified.' 198 self.logger.warning(msg) 199 raise TypeError(msg) 200 201 self.akey = validate_regex(akey) 202 if not self.akey: 203 msg = 'An invalid Twitter Access Key was specified.' 204 self.logger.warning(msg) 205 raise TypeError(msg) 206 207 self.asecret = validate_regex(asecret) 208 if not self.asecret: 209 msg = 'An invalid Access Secret was specified.' 210 self.logger.warning(msg) 211 raise TypeError(msg) 212 213 # Store our webhook mode 214 self.mode = None \ 215 if not isinstance(mode, six.string_types) else mode.lower() 216 217 # Set Cache Flag 218 self.cache = cache 219 220 if self.mode not in TWITTER_MESSAGE_MODES: 221 msg = 'The Twitter message mode specified ({}) is invalid.' \ 222 .format(mode) 223 self.logger.warning(msg) 224 raise TypeError(msg) 225 226 # Track any errors 227 has_error = False 228 229 # Identify our targets 230 self.targets = [] 231 for target in parse_list(targets): 232 match = IS_USER.match(target) 233 if match and match.group('user'): 234 self.targets.append(match.group('user')) 235 continue 236 237 has_error = True 238 self.logger.warning( 239 'Dropped invalid user ({}) specified.'.format(target), 240 ) 241 242 if has_error and not self.targets: 243 # We have specified that we want to notify one or more individual 244 # and we failed to load any of them. Since it's also valid to 245 # notify no one at all (which means we notify ourselves), it's 246 # important we don't switch from the users original intentions 247 msg = 'No Twitter targets to notify.' 248 self.logger.warning(msg) 249 raise TypeError(msg) 250 251 return 252 253 def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): 254 """ 255 Perform Twitter Notification 256 """ 257 258 # Call the _send_ function applicable to whatever mode we're in 259 # - calls _send_tweet if the mode is set so 260 # - calls _send_dm (direct message) otherwise 261 return getattr(self, '_send_{}'.format(self.mode))( 262 body=body, title=title, notify_type=notify_type, **kwargs) 263 264 def _send_tweet(self, body, title='', notify_type=NotifyType.INFO, 265 **kwargs): 266 """ 267 Twitter Public Tweet 268 """ 269 270 payload = { 271 'status': body, 272 } 273 274 # Send Tweet 275 postokay, response = self._fetch( 276 self.twitter_tweet, 277 payload=payload, 278 json=False, 279 ) 280 281 if postokay: 282 self.logger.info( 283 'Sent Twitter notification as public tweet.') 284 285 return postokay 286 287 def _send_dm(self, body, title='', notify_type=NotifyType.INFO, 288 **kwargs): 289 """ 290 Twitter Direct Message 291 """ 292 293 # Error Tracking 294 has_error = False 295 296 payload = { 297 'event': { 298 'type': 'message_create', 299 'message_create': { 300 'target': { 301 # This gets assigned 302 'recipient_id': None, 303 }, 304 'message_data': { 305 'text': body, 306 } 307 } 308 } 309 } 310 311 # Lookup our users (otherwise we look up ourselves) 312 targets = self._whoami(lazy=self.cache) if not len(self.targets) \ 313 else self._user_lookup(self.targets, lazy=self.cache) 314 315 if not targets: 316 # We failed to lookup any users 317 self.logger.warning( 318 'Failed to acquire user(s) to Direct Message via Twitter') 319 return False 320 321 for screen_name, user_id in targets.items(): 322 # Assign our user 323 payload['event']['message_create']['target']['recipient_id'] = \ 324 user_id 325 326 # Send Twitter DM 327 postokay, response = self._fetch( 328 self.twitter_dm, 329 payload=payload, 330 ) 331 332 if not postokay: 333 # Track our error 334 has_error = True 335 continue 336 337 self.logger.info( 338 'Sent Twitter DM notification to @{}.'.format(screen_name)) 339 340 return not has_error 341 342 def _whoami(self, lazy=True): 343 """ 344 Looks details of current authenticated user 345 346 """ 347 348 # Prepare a whoami key; this is to prevent conflict with other 349 # NotifyTwitter declarations that may or may not use a different 350 # set of authentication keys 351 whoami_key = '{}{}{}{}'.format( 352 self.ckey, self.csecret, self.akey, self.asecret) 353 354 if lazy and hasattr(NotifyTwitter, '_whoami_cache') \ 355 and whoami_key in getattr(NotifyTwitter, '_whoami_cache'): 356 # Use cached response 357 return getattr(NotifyTwitter, '_whoami_cache')[whoami_key] 358 359 # Contains a mapping of screen_name to id 360 results = {} 361 362 # Send Twitter DM 363 postokay, response = self._fetch( 364 self.twitter_whoami, 365 method='GET', 366 json=False, 367 ) 368 369 if postokay: 370 try: 371 results[response['screen_name']] = response['id'] 372 373 if lazy: 374 # Cache our response for future references 375 if not hasattr(NotifyTwitter, '_whoami_cache'): 376 setattr( 377 NotifyTwitter, '_whoami_cache', 378 {whoami_key: results}) 379 else: 380 getattr(NotifyTwitter, '_whoami_cache')\ 381 .update({whoami_key: results}) 382 383 # Update our user cache as well 384 if not hasattr(NotifyTwitter, '_user_cache'): 385 setattr(NotifyTwitter, '_user_cache', results) 386 else: 387 getattr(NotifyTwitter, '_user_cache').update(results) 388 389 except (TypeError, KeyError): 390 pass 391 392 return results 393 394 def _user_lookup(self, screen_name, lazy=True): 395 """ 396 Looks up a screen name and returns the user id 397 398 the screen_name can be a list/set/tuple as well 399 """ 400 401 # Contains a mapping of screen_name to id 402 results = {} 403 404 # Build a unique set of names 405 names = parse_list(screen_name) 406 407 if lazy and hasattr(NotifyTwitter, '_user_cache'): 408 # Use cached response 409 results = {k: v for k, v in getattr( 410 NotifyTwitter, '_user_cache').items() if k in names} 411 412 # limit our names if they already exist in our cache 413 names = [name for name in names if name not in results] 414 415 if not len(names): 416 # They're is nothing further to do 417 return results 418 419 # Twitters API documents that it can lookup to 100 420 # results at a time. 421 # https://developer.twitter.com/en/docs/accounts-and-users/\ 422 # follow-search-get-users/api-reference/get-users-lookup 423 for i in range(0, len(names), 100): 424 # Send Twitter DM 425 postokay, response = self._fetch( 426 self.twitter_lookup, 427 payload={ 428 'screen_name': names[i:i + 100], 429 }, 430 json=False, 431 ) 432 433 if not postokay or not isinstance(response, list): 434 # Track our error 435 continue 436 437 # Update our user index 438 for entry in response: 439 try: 440 results[entry['screen_name']] = entry['id'] 441 442 except (TypeError, KeyError): 443 pass 444 445 # Cache our response for future use; this saves on un-nessisary extra 446 # hits against the Twitter API when we already know the answer 447 if lazy: 448 if not hasattr(NotifyTwitter, '_user_cache'): 449 setattr(NotifyTwitter, '_user_cache', results) 450 else: 451 getattr(NotifyTwitter, '_user_cache').update(results) 452 453 return results 454 455 def _fetch(self, url, payload=None, method='POST', json=True): 456 """ 457 Wrapper to Twitter API requests object 458 """ 459 460 headers = { 461 'Host': self.twitter_api, 462 'User-Agent': self.app_id, 463 } 464 465 if json: 466 headers['Content-Type'] = 'application/json' 467 payload = dumps(payload) 468 469 auth = OAuth1( 470 self.ckey, 471 client_secret=self.csecret, 472 resource_owner_key=self.akey, 473 resource_owner_secret=self.asecret, 474 ) 475 476 # Some Debug Logging 477 self.logger.debug('Twitter {} URL: {} (cert_verify={})'.format( 478 method, url, self.verify_certificate)) 479 self.logger.debug('Twitter Payload: %s' % str(payload)) 480 481 # By default set wait to None 482 wait = None 483 484 if self.ratelimit_remaining == 0: 485 # Determine how long we should wait for or if we should wait at 486 # all. This isn't fool-proof because we can't be sure the client 487 # time (calling this script) is completely synced up with the 488 # Gitter server. One would hope we're on NTP and our clocks are 489 # the same allowing this to role smoothly: 490 491 now = datetime.utcnow() 492 if now < self.ratelimit_reset: 493 # We need to throttle for the difference in seconds 494 # We add 0.5 seconds to the end just to allow a grace 495 # period. 496 wait = (self.ratelimit_reset - now).total_seconds() + 0.5 497 498 # Default content response object 499 content = {} 500 501 # Always call throttle before any remote server i/o is made; 502 self.throttle(wait=wait) 503 504 # acquire our request mode 505 fn = requests.post if method == 'POST' else requests.get 506 try: 507 r = fn( 508 url, 509 data=payload, 510 headers=headers, 511 auth=auth, 512 verify=self.verify_certificate, 513 timeout=self.request_timeout, 514 ) 515 516 if r.status_code != requests.codes.ok: 517 # We had a problem 518 status_str = \ 519 NotifyTwitter.http_response_code_lookup(r.status_code) 520 521 self.logger.warning( 522 'Failed to send Twitter {} to {}: ' 523 '{}error={}.'.format( 524 method, 525 url, 526 ', ' if status_str else '', 527 r.status_code)) 528 529 self.logger.debug( 530 'Response Details:\r\n{}'.format(r.content)) 531 532 # Mark our failure 533 return (False, content) 534 535 try: 536 content = loads(r.content) 537 538 except (AttributeError, TypeError, ValueError): 539 # ValueError = r.content is Unparsable 540 # TypeError = r.content is None 541 # AttributeError = r is None 542 content = {} 543 544 try: 545 # Capture rate limiting if possible 546 self.ratelimit_remaining = \ 547 int(r.headers.get('x-rate-limit-remaining')) 548 self.ratelimit_reset = datetime.utcfromtimestamp( 549 int(r.headers.get('x-rate-limit-reset'))) 550 551 except (TypeError, ValueError): 552 # This is returned if we could not retrieve this information 553 # gracefully accept this state and move on 554 pass 555 556 except requests.RequestException as e: 557 self.logger.warning( 558 'Exception received when sending Twitter {} to {}: '. 559 format(method, url)) 560 self.logger.debug('Socket Exception: %s' % str(e)) 561 562 # Mark our failure 563 return (False, content) 564 565 return (True, content) 566 567 @property 568 def body_maxlen(self): 569 """ 570 The maximum allowable characters allowed in the body per message 571 This is used during a Private DM Message Size (not Public Tweets 572 which are limited to 280 characters) 573 """ 574 return 10000 if self.mode == TwitterMessageMode.DM else 280 575 576 def url(self, privacy=False, *args, **kwargs): 577 """ 578 Returns the URL built dynamically based on specified arguments. 579 """ 580 581 # Define any URL parameters 582 params = { 583 'mode': self.mode, 584 } 585 586 # Extend our parameters 587 params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) 588 589 if len(self.targets) > 0: 590 params['to'] = ','.join( 591 [NotifyTwitter.quote(x, safe='') for x in self.targets]) 592 593 return '{schema}://{ckey}/{csecret}/{akey}/{asecret}' \ 594 '/{targets}/?{params}'.format( 595 schema=self.secure_protocol, 596 ckey=self.pprint(self.ckey, privacy, safe=''), 597 csecret=self.pprint( 598 self.csecret, privacy, mode=PrivacyMode.Secret, safe=''), 599 akey=self.pprint(self.akey, privacy, safe=''), 600 asecret=self.pprint( 601 self.asecret, privacy, mode=PrivacyMode.Secret, safe=''), 602 targets='/'.join( 603 [NotifyTwitter.quote('@{}'.format(target), safe='') 604 for target in self.targets]), 605 params=NotifyTwitter.urlencode(params)) 606 607 @staticmethod 608 def parse_url(url): 609 """ 610 Parses the URL and returns enough arguments that can allow 611 us to re-instantiate this object. 612 613 """ 614 results = NotifyBase.parse_url(url, verify_host=False) 615 if not results: 616 # We're done early as we couldn't load the results 617 return results 618 619 # The first token is stored in the hostname 620 consumer_key = NotifyTwitter.unquote(results['host']) 621 622 # Acquire remaining tokens 623 tokens = NotifyTwitter.split_path(results['fullpath']) 624 625 # Now fetch the remaining tokens 626 try: 627 consumer_secret, access_token_key, access_token_secret = \ 628 tokens[0:3] 629 630 except (ValueError, AttributeError, IndexError): 631 # Force some bad values that will get caught 632 # in parsing later 633 consumer_secret = None 634 access_token_key = None 635 access_token_secret = None 636 637 results['ckey'] = consumer_key 638 results['csecret'] = consumer_secret 639 results['akey'] = access_token_key 640 results['asecret'] = access_token_secret 641 642 # The defined twitter mode 643 if 'mode' in results['qsd'] and len(results['qsd']['mode']): 644 results['mode'] = \ 645 NotifyTwitter.unquote(results['qsd']['mode']) 646 647 results['targets'] = [] 648 649 # if a user has been defined, add it to the list of targets 650 if results.get('user'): 651 results['targets'].append(results.get('user')) 652 653 # Store any remaining items as potential targets 654 results['targets'].extend(tokens[3:]) 655 656 if 'cache' in results['qsd'] and len(results['qsd']['cache']): 657 results['cache'] = \ 658 parse_bool(results['qsd']['cache'], True) 659 660 # The 'to' makes it easier to use yaml configuration 661 if 'to' in results['qsd'] and len(results['qsd']['to']): 662 results['targets'] += \ 663 NotifyTwitter.parse_list(results['qsd']['to']) 664 665 return results 666