diff --git a/apprise/plugins/NotifyTwitter.py b/apprise/plugins/NotifyTwitter.py index 437cd1e8..364eca41 100644 --- a/apprise/plugins/NotifyTwitter.py +++ b/apprise/plugins/NotifyTwitter.py @@ -28,6 +28,7 @@ import re import six import requests +from copy import deepcopy from datetime import datetime from requests_oauthlib import OAuth1 from json import dumps @@ -39,6 +40,7 @@ from ..utils import parse_list from ..utils import parse_bool from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ +from ..attachment.AttachBase import AttachBase IS_USER = re.compile(r'^\s*@?(?P[A-Z0-9_]+)$', re.I) @@ -87,9 +89,6 @@ class NotifyTwitter(NotifyBase): # Twitter does have titles when creating a message title_maxlen = 0 - # Twitter API - twitter_api = 'api.twitter.com' - # Twitter API Reference To Acquire Someone's Twitter ID twitter_lookup = 'https://api.twitter.com/1.1/users/lookup.json' @@ -103,6 +102,13 @@ class NotifyTwitter(NotifyBase): # Twitter API Reference To Send A Public Tweet twitter_tweet = 'https://api.twitter.com/1.1/statuses/update.json' + # it is documented on the site that the maximum images per tweet + # is 4 (unless it's a GIF, then it's only 1) + __tweet_non_gif_images_batch = 4 + + # Twitter Media (Attachment) Upload Location + twitter_media = 'https://upload.twitter.com/1.1/media/upload.json' + # Twitter is kind enough to return how many more requests we're allowed to # continue to make within it's header response as: # X-Rate-Limit-Reset: The epoc time (in seconds) we can expect our @@ -176,10 +182,15 @@ class NotifyTwitter(NotifyBase): 'to': { 'alias_of': 'targets', }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': True, + }, }) def __init__(self, ckey, csecret, akey, asecret, targets=None, - mode=TwitterMessageMode.DM, cache=True, **kwargs): + mode=TwitterMessageMode.DM, cache=True, batch=True, **kwargs): """ Initialize Twitter Object @@ -217,6 +228,9 @@ class NotifyTwitter(NotifyBase): # Set Cache Flag self.cache = cache + # Prepare Image Batch Mode Flag + self.batch = batch + if self.mode not in TWITTER_MESSAGE_MODES: msg = 'The Twitter message mode specified ({}) is invalid.' \ .format(mode) @@ -250,42 +264,171 @@ class NotifyTwitter(NotifyBase): return - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): """ Perform Twitter Notification """ - # Call the _send_ function applicable to whatever mode we're in + # Build a list of our attachments + attachments = [] + + if attach: + # We need to upload our payload first so that we can source it + # in remaining messages + for attachment in attach: + + # Perform some simple error checking + if not attachment: + # We could not access the attachment + self.logger.error( + 'Could not access attachment {}.'.format( + attachment.url(privacy=True))) + return False + + if not re.match(r'^image/.*', attachment.mimetype, re.I): + # Only support images at this time + self.logger.warning( + 'Ignoring unsupported Twitter attachment {}.'.format( + attachment.url(privacy=True))) + continue + + self.logger.debug( + 'Preparing Twiter attachment {}'.format( + attachment.url(privacy=True))) + + # Upload our image and get our id associated with it + # see: https://developer.twitter.com/en/docs/twitter-api/v1/\ + # media/upload-media/api-reference/post-media-upload + postokay, response = self._fetch( + self.twitter_media, + payload=attachment, + ) + + if not postokay: + # We can't post our attachment + return False + + if not (isinstance(response, dict) + and response.get('media_id')): + self.logger.debug( + 'Could not attach the file to Twitter: %s (mime=%s)', + attachment.name, attachment.mimetype) + continue + + # If we get here, our output will look something like this: + # { + # "media_id": 710511363345354753, + # "media_id_string": "710511363345354753", + # "media_key": "3_710511363345354753", + # "size": 11065, + # "expires_after_secs": 86400, + # "image": { + # "image_type": "image/jpeg", + # "w": 800, + # "h": 320 + # } + # } + + response.update({ + # Update our response to additionally include the + # attachment details + 'file_name': attachment.name, + 'file_mime': attachment.mimetype, + 'file_path': attachment.path, + }) + + # Save our pre-prepared payload for attachment posting + attachments.append(response) + # - calls _send_tweet if the mode is set so # - calls _send_dm (direct message) otherwise return getattr(self, '_send_{}'.format(self.mode))( - body=body, title=title, notify_type=notify_type, **kwargs) + body=body, title=title, notify_type=notify_type, + attachments=attachments, **kwargs) def _send_tweet(self, body, title='', notify_type=NotifyType.INFO, - **kwargs): + attachments=None, **kwargs): """ Twitter Public Tweet """ + # Error Tracking + has_error = False + payload = { 'status': body, } - # Send Tweet - postokay, response = self._fetch( - self.twitter_tweet, - payload=payload, - json=False, - ) + payloads = [] + if not attachments: + payloads.append(payload) + + else: + # Group our images if batch is set to do so + batch_size = 1 if not self.batch \ + else self.__tweet_non_gif_images_batch + + # Track our batch control in our message generation + batches = [] + batch = [] + for attachment in attachments: + batch.append(str(attachment['media_id'])) + + # Twitter supports batching images together. This allows + # the batching of multiple images together. Twitter also + # makes it clear that you can't batch `gif` files; they need + # to be separate. So the below preserves the ordering that + # a user passed their attachments in. if 4-non-gif images + # are passed, they are all part of a single message. + # + # however, if they pass in image, gif, image, gif. The + # gif's inbetween break apart the batches so this would + # produce 4 separate tweets. + # + # If you passed in, image, image, gif, image. <- This would + # produce 3 images (as the first 2 images could be lumped + # together as a batch) + if not re.match( + r'^image/(png|jpe?g)', attachment['file_mime'], re.I) \ + or len(batch) >= batch_size: + batches.append(','.join(batch)) + batch = [] + + if batch: + batches.append(','.join(batch)) + + for no, media_ids in enumerate(batches): + _payload = deepcopy(payload) + _payload['media_ids'] = media_ids + + if no: + # strip text and replace it with the image representation + _payload['status'] = \ + '{:02d}/{:02d}'.format(no + 1, len(batches)) + payloads.append(_payload) + + for no, payload in enumerate(payloads, start=1): + # Send Tweet + postokay, response = self._fetch( + self.twitter_tweet, + payload=payload, + json=False, + ) + + if not postokay: + # Track our error + has_error = True + continue - if postokay: self.logger.info( - 'Sent Twitter notification as public tweet.') + 'Sent [{:02d}/{:02d}] Twitter notification as public tweet.' + .format(no, len(payloads))) - return postokay + return not has_error def _send_dm(self, body, title='', notify_type=NotifyType.INFO, - **kwargs): + attachments=None, **kwargs): """ Twitter Direct Message """ @@ -318,24 +461,48 @@ class NotifyTwitter(NotifyBase): 'Failed to acquire user(s) to Direct Message via Twitter') return False - for screen_name, user_id in targets.items(): - # Assign our user - payload['event']['message_create']['target']['recipient_id'] = \ - user_id + payloads = [] + if not attachments: + payloads.append(payload) - # Send Twitter DM - postokay, response = self._fetch( - self.twitter_dm, - payload=payload, - ) + else: + for no, attachment in enumerate(attachments): + _payload = deepcopy(payload) + _data = _payload['event']['message_create']['message_data'] + _data['attachment'] = { + 'type': 'media', + 'media': { + 'id': attachment['media_id'] + }, + 'additional_owners': + ','.join([str(x) for x in targets.values()]) + } + if no: + # strip text and replace it with the image representation + _data['text'] = \ + '{:02d}/{:02d}'.format(no + 1, len(attachments)) + payloads.append(_payload) - if not postokay: - # Track our error - has_error = True - continue + for no, payload in enumerate(payloads, start=1): + for screen_name, user_id in targets.items(): + # Assign our user + target = payload['event']['message_create']['target'] + target['recipient_id'] = user_id - self.logger.info( - 'Sent Twitter DM notification to @{}.'.format(screen_name)) + # Send Twitter DM + postokay, response = self._fetch( + self.twitter_dm, + payload=payload, + ) + + if not postokay: + # Track our error + has_error = True + continue + + self.logger.info( + 'Sent [{:02d}/{:02d}] Twitter DM notification to @{}.' + .format(no, len(payloads), screen_name)) return not has_error @@ -458,13 +625,23 @@ class NotifyTwitter(NotifyBase): """ headers = { - 'Host': self.twitter_api, 'User-Agent': self.app_id, } - if json: + data = None + files = None + + # Open our attachment path if required: + if isinstance(payload, AttachBase): + # prepare payload + files = {'media': (payload.name, open(payload.path, 'rb'))} + + elif json: headers['Content-Type'] = 'application/json' - payload = dumps(payload) + data = dumps(payload) + + else: + data = payload auth = OAuth1( self.ckey, @@ -506,7 +683,8 @@ class NotifyTwitter(NotifyBase): try: r = fn( url, - data=payload, + data=data, + files=files, headers=headers, auth=auth, verify=self.verify_certificate, @@ -562,6 +740,20 @@ class NotifyTwitter(NotifyBase): # Mark our failure return (False, content) + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while handling {}.'.format( + payload.name if isinstance(payload, AttachBase) + else payload)) + self.logger.debug('I/O Exception: %s' % str(e)) + return (False, content) + + finally: + # Close our file (if it's open) stored in the second element + # of our files tuple (index 1) + if files: + files['media'][1].close() + return (True, content) @property @@ -581,6 +773,8 @@ class NotifyTwitter(NotifyBase): # Define any URL parameters params = { 'mode': self.mode, + 'batch': 'yes' if self.batch else 'no', + 'cache': 'yes' if self.cache else 'no', } # Extend our parameters @@ -653,10 +847,16 @@ class NotifyTwitter(NotifyBase): # Store any remaining items as potential targets results['targets'].extend(tokens[3:]) + # Get Cache Flag (reduces lookup hits) if 'cache' in results['qsd'] and len(results['qsd']['cache']): results['cache'] = \ parse_bool(results['qsd']['cache'], True) + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get( + 'batch', NotifyTwitter.template_args['batch']['default'])) + # The 'to' makes it easier to use yaml configuration if 'to' in results['qsd'] and len(results['qsd']['to']): results['targets'] += \ diff --git a/test/test_plugin_twitter.py b/test/test_plugin_twitter.py index 8c7e62f4..8ac20fde 100644 --- a/test/test_plugin_twitter.py +++ b/test/test_plugin_twitter.py @@ -23,19 +23,26 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. +import os import six import mock import pytest import requests from json import dumps from datetime import datetime +from apprise import Apprise from apprise import plugins +from apprise import NotifyType +from apprise import AppriseAttachment from helpers import AppriseURLTester # Disable logging for a cleaner testing output import logging logging.disable(logging.CRITICAL) +# Attachment Directory +TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), 'var') + # Our Testing URLs apprise_url_tests = ( ################################## @@ -77,7 +84,9 @@ apprise_url_tests = ( # However we'll be okay if we return a proper response 'requests_response_text': { 'id': 12345, - 'screen_name': 'test' + 'screen_name': 'test', + # For attachment handling + 'media_id': 123, }, }), ('twitter://consumer_key/consumer_secret/access_token/access_secret', { @@ -86,7 +95,9 @@ apprise_url_tests = ( # However we'll be okay if we return a proper response 'requests_response_text': { 'id': 12345, - 'screen_name': 'test' + 'screen_name': 'test', + # For attachment handling + 'media_id': 123, }, }), # A duplicate of the entry above, this will cause cache to be referenced @@ -96,7 +107,9 @@ apprise_url_tests = ( # However we'll be okay if we return a proper response 'requests_response_text': { 'id': 12345, - 'screen_name': 'test' + 'screen_name': 'test', + # For attachment handling + 'media_id': 123, }, }), # handle cases where the screen_name is missing from the response causing @@ -107,6 +120,8 @@ apprise_url_tests = ( # However we'll be okay if we return a proper response 'requests_response_text': { 'id': 12345, + # For attachment handling + 'media_id': 123, }, # due to a mangled response_text we'll fail 'notify_response': False, @@ -119,8 +134,8 @@ apprise_url_tests = ( 'notify_response': False, }), ('twitter://user@consumer_key/csecret/access_token/access_secret' - '?cache=No', { - # No Cache + '?cache=No&batch=No', { + # No Cache & No Batch 'instance': plugins.NotifyTwitter, 'requests_response_text': [{ 'id': 12345, @@ -404,3 +419,465 @@ def test_plugin_twitter_edge_cases(): plugins.NotifyTwitter( ckey='value', csecret='value', akey='value', asecret='value', targets='%G@rB@g3') + + +@mock.patch('requests.post') +@mock.patch('requests.get') +def test_plugin_twitter_dm_attachments(mock_get, mock_post): + """ + NotifyTwitter() DM Attachment Checks + + """ + ckey = 'ckey' + csecret = 'csecret' + akey = 'akey' + asecret = 'asecret' + screen_name = 'apprise' + + good_dm_response_obj = { + 'screen_name': screen_name, + 'id': 9876, + } + + # Disable Throttling to speed testing + plugins.NotifyBase.request_rate_per_sec = 0 + + # Prepare a good DM response + good_dm_response = mock.Mock() + good_dm_response.content = dumps(good_dm_response_obj) + good_dm_response.status_code = requests.codes.ok + + # Prepare bad response + bad_response = mock.Mock() + bad_response.content = dumps({}) + bad_response.status_code = requests.codes.internal_server_error + + # Prepare a good media response + good_media_response = mock.Mock() + good_media_response.content = dumps({ + "media_id": 710511363345354753, + "media_id_string": "710511363345354753", + "media_key": "3_710511363345354753", + "size": 11065, + "expires_after_secs": 86400, + "image": { + "image_type": "image/jpeg", + "w": 800, + "h": 320 + } + }) + good_media_response.status_code = requests.codes.ok + + # Prepare a bad media response + bad_media_response = mock.Mock() + bad_media_response.content = dumps({ + "errors": [ + { + "code": 93, + "message": "This application is not allowed to access or " + "delete your direct messages.", + }]}) + bad_media_response.status_code = requests.codes.internal_server_error + + mock_post.side_effect = [good_media_response, good_dm_response] + mock_get.return_value = good_dm_response + + twitter_url = 'twitter://{}/{}/{}/{}'.format(ckey, csecret, akey, asecret) + + # attach our content + attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, 'apprise-test.gif')) + + # instantiate our object + obj = Apprise.instantiate(twitter_url) + + # Send our notification + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is True + + # Test our call count + assert mock_get.call_count == 1 + assert mock_get.call_args_list[0][0][0] == \ + 'https://api.twitter.com/1.1/account/verify_credentials.json' + assert mock_post.call_count == 2 + assert mock_post.call_args_list[0][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' + assert mock_post.call_args_list[1][0][0] == \ + 'https://api.twitter.com/1.1/direct_messages/events/new.json' + + mock_get.reset_mock() + mock_post.reset_mock() + + # Test case where upload fails + mock_get.return_value = good_dm_response + mock_post.side_effect = [bad_media_response, good_dm_response] + + # instantiate our object + obj = Apprise.instantiate(twitter_url) + + # Send our notification; it will fail because of the media response + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is False + + # Test our call count + assert mock_get.call_count == 0 + # No get request as cached response is used + assert mock_post.call_count == 1 + assert mock_post.call_args_list[0][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' + + mock_get.reset_mock() + mock_post.reset_mock() + + # Test case where upload fails + mock_get.return_value = good_dm_response + mock_post.side_effect = [good_media_response, bad_response] + + # instantiate our object + obj = Apprise.instantiate(twitter_url) + + # Send our notification; it will fail because of the media response + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is False + + assert mock_get.call_count == 0 + # No get request as cached response is used + assert mock_post.call_count == 2 + assert mock_post.call_args_list[0][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' + assert mock_post.call_args_list[1][0][0] == \ + 'https://api.twitter.com/1.1/direct_messages/events/new.json' + + mock_get.reset_mock() + mock_post.reset_mock() + + mock_post.side_effect = [good_media_response, good_dm_response] + mock_get.return_value = good_dm_response + + # An invalid attachment will cause a failure + path = os.path.join(TEST_VAR_DIR, '/invalid/path/to/an/invalid/file.jpg') + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=path) is False + + # No get request as cached response is used + assert mock_get.call_count == 0 + + # No post request as attachment is no good anyway + assert mock_post.call_count == 0 + + mock_get.reset_mock() + mock_post.reset_mock() + + mock_post.side_effect = [ + good_media_response, good_media_response, good_media_response, + good_media_response, good_dm_response, good_dm_response, + good_dm_response, good_dm_response] + mock_get.return_value = good_dm_response + + # 4 images are produced + attach = [ + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + os.path.join(TEST_VAR_DIR, 'apprise-test.jpeg'), + os.path.join(TEST_VAR_DIR, 'apprise-test.png'), + # This one is not supported, so it's ignored gracefully + os.path.join(TEST_VAR_DIR, 'apprise-test.mp4'), + ] + + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is True + + assert mock_get.call_count == 0 + # No get request as cached response is used + assert mock_post.call_count == 8 + assert mock_post.call_args_list[0][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' + assert mock_post.call_args_list[1][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' + assert mock_post.call_args_list[2][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' + assert mock_post.call_args_list[3][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' + assert mock_post.call_args_list[4][0][0] == \ + 'https://api.twitter.com/1.1/direct_messages/events/new.json' + assert mock_post.call_args_list[5][0][0] == \ + 'https://api.twitter.com/1.1/direct_messages/events/new.json' + assert mock_post.call_args_list[6][0][0] == \ + 'https://api.twitter.com/1.1/direct_messages/events/new.json' + assert mock_post.call_args_list[7][0][0] == \ + 'https://api.twitter.com/1.1/direct_messages/events/new.json' + + mock_get.reset_mock() + mock_post.reset_mock() + + # We have an OSError thrown in the middle of our preparation + mock_post.side_effect = [good_media_response, OSError()] + mock_get.return_value = good_dm_response + + # 2 images are produced + attach = [ + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + os.path.join(TEST_VAR_DIR, 'apprise-test.png'), + # This one is not supported, so it's ignored gracefully + os.path.join(TEST_VAR_DIR, 'apprise-test.mp4'), + ] + + # We'll fail to send this time + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is False + + assert mock_get.call_count == 0 + # No get request as cached response is used + assert mock_post.call_count == 2 + assert mock_post.call_args_list[0][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' + assert mock_post.call_args_list[1][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' + + +@mock.patch('requests.post') +@mock.patch('requests.get') +def test_plugin_twitter_tweet_attachments(mock_get, mock_post): + """ + NotifyTwitter() Tweet Attachment Checks + + """ + ckey = 'ckey' + csecret = 'csecret' + akey = 'akey' + asecret = 'asecret' + screen_name = 'apprise' + + good_tweet_response_obj = { + 'screen_name': screen_name, + 'id': 9876, + } + + # Disable Throttling to speed testing + plugins.NotifyBase.request_rate_per_sec = 0 + + # Prepare a good DM response + good_tweet_response = mock.Mock() + good_tweet_response.content = dumps(good_tweet_response_obj) + good_tweet_response.status_code = requests.codes.ok + + # Prepare bad response + bad_response = mock.Mock() + bad_response.content = dumps({}) + bad_response.status_code = requests.codes.internal_server_error + + # Prepare a good media response + good_media_response = mock.Mock() + good_media_response.content = dumps({ + "media_id": 710511363345354753, + "media_id_string": "710511363345354753", + "media_key": "3_710511363345354753", + "size": 11065, + "expires_after_secs": 86400, + "image": { + "image_type": "image/jpeg", + "w": 800, + "h": 320 + } + }) + good_media_response.status_code = requests.codes.ok + + # Prepare a bad media response + bad_media_response = mock.Mock() + bad_media_response.content = dumps({ + "errors": [ + { + "code": 93, + "message": "This application is not allowed to access or " + "delete your direct messages.", + }]}) + bad_media_response.status_code = requests.codes.internal_server_error + + mock_post.side_effect = [good_media_response, good_tweet_response] + mock_get.return_value = good_tweet_response + + twitter_url = 'twitter://{}/{}/{}/{}?mode=tweet'.format( + ckey, csecret, akey, asecret) + + # attach our content + attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, 'apprise-test.gif')) + + # instantiate our object + obj = Apprise.instantiate(twitter_url) + + # Send our notification + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is True + + # Test our call count + assert mock_get.call_count == 0 + assert mock_post.call_count == 2 + assert mock_post.call_args_list[0][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' + assert mock_post.call_args_list[1][0][0] == \ + 'https://api.twitter.com/1.1/statuses/update.json' + + mock_get.reset_mock() + mock_post.reset_mock() + + # Test case where upload fails + mock_get.return_value = good_tweet_response + mock_post.side_effect = [good_media_response, bad_response] + + # instantiate our object + obj = Apprise.instantiate(twitter_url) + + # Send our notification; it will fail because of the media response + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is False + + assert mock_get.call_count == 0 + assert mock_post.call_count == 2 + assert mock_post.call_args_list[0][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' + assert mock_post.call_args_list[1][0][0] == \ + 'https://api.twitter.com/1.1/statuses/update.json' + + mock_get.reset_mock() + mock_post.reset_mock() + + mock_post.side_effect = [good_media_response, good_tweet_response] + mock_get.return_value = good_tweet_response + + # An invalid attachment will cause a failure + path = os.path.join(TEST_VAR_DIR, '/invalid/path/to/an/invalid/file.jpg') + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=path) is False + + # No get request as cached response is used + assert mock_get.call_count == 0 + + # No post request as attachment is no good anyway + assert mock_post.call_count == 0 + + mock_get.reset_mock() + mock_post.reset_mock() + + mock_post.side_effect = [ + good_media_response, good_media_response, good_media_response, + good_media_response, good_tweet_response, good_tweet_response, + good_tweet_response, good_tweet_response] + mock_get.return_value = good_tweet_response + + # instantiate our object (without a batch mode) + obj = Apprise.instantiate(twitter_url + "&batch=no") + + # 4 images are produced + attach = [ + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + os.path.join(TEST_VAR_DIR, 'apprise-test.jpeg'), + os.path.join(TEST_VAR_DIR, 'apprise-test.png'), + # This one is not supported, so it's ignored gracefully + os.path.join(TEST_VAR_DIR, 'apprise-test.mp4'), + ] + + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is True + + assert mock_get.call_count == 0 + # No get request as cached response is used + assert mock_post.call_count == 8 + assert mock_post.call_args_list[0][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' + assert mock_post.call_args_list[1][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' + assert mock_post.call_args_list[2][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' + assert mock_post.call_args_list[3][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' + assert mock_post.call_args_list[4][0][0] == \ + 'https://api.twitter.com/1.1/statuses/update.json' + assert mock_post.call_args_list[5][0][0] == \ + 'https://api.twitter.com/1.1/statuses/update.json' + assert mock_post.call_args_list[6][0][0] == \ + 'https://api.twitter.com/1.1/statuses/update.json' + assert mock_post.call_args_list[7][0][0] == \ + 'https://api.twitter.com/1.1/statuses/update.json' + + mock_get.reset_mock() + mock_post.reset_mock() + + mock_post.side_effect = [ + good_media_response, good_media_response, good_media_response, + good_media_response, good_tweet_response, good_tweet_response, + good_tweet_response, good_tweet_response] + mock_get.return_value = good_tweet_response + + # instantiate our object + obj = Apprise.instantiate(twitter_url) + + # 4 images are produced + attach = [ + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + os.path.join(TEST_VAR_DIR, 'apprise-test.jpeg'), + os.path.join(TEST_VAR_DIR, 'apprise-test.png'), + # This one is not supported, so it's ignored gracefully + os.path.join(TEST_VAR_DIR, 'apprise-test.mp4'), + ] + + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is True + + assert mock_get.call_count == 0 + # No get request as cached response is used + assert mock_post.call_count == 7 + assert mock_post.call_args_list[0][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' + assert mock_post.call_args_list[1][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' + assert mock_post.call_args_list[2][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' + assert mock_post.call_args_list[3][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' + assert mock_post.call_args_list[4][0][0] == \ + 'https://api.twitter.com/1.1/statuses/update.json' + assert mock_post.call_args_list[5][0][0] == \ + 'https://api.twitter.com/1.1/statuses/update.json' + # The 2 images are grouped together (batch mode) + assert mock_post.call_args_list[6][0][0] == \ + 'https://api.twitter.com/1.1/statuses/update.json' + + mock_get.reset_mock() + mock_post.reset_mock() + + # We have an OSError thrown in the middle of our preparation + mock_post.side_effect = [good_media_response, OSError()] + mock_get.return_value = good_tweet_response + + # 2 images are produced + attach = [ + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + os.path.join(TEST_VAR_DIR, 'apprise-test.png'), + # This one is not supported, so it's ignored gracefully + os.path.join(TEST_VAR_DIR, 'apprise-test.mp4'), + ] + + # We'll fail to send this time + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is False + + assert mock_get.call_count == 0 + # No get request as cached response is used + assert mock_post.call_count == 2 + assert mock_post.call_args_list[0][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' + assert mock_post.call_args_list[1][0][0] == \ + 'https://upload.twitter.com/1.1/media/upload.json' diff --git a/test/var/apprise-test.mp4 b/test/var/apprise-test.mp4 new file mode 100644 index 00000000..74508716 Binary files /dev/null and b/test/var/apprise-test.mp4 differ