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