mirror of
https://github.com/caronc/apprise.git
synced 2025-01-03 20:49:19 +01:00
Twitter Image Attachment Support Added (#536)
This commit is contained in:
parent
e73025863b
commit
2a81899e6e
@ -28,6 +28,7 @@
|
|||||||
import re
|
import re
|
||||||
import six
|
import six
|
||||||
import requests
|
import requests
|
||||||
|
from copy import deepcopy
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from requests_oauthlib import OAuth1
|
from requests_oauthlib import OAuth1
|
||||||
from json import dumps
|
from json import dumps
|
||||||
@ -39,6 +40,7 @@ from ..utils import parse_list
|
|||||||
from ..utils import parse_bool
|
from ..utils import parse_bool
|
||||||
from ..utils import validate_regex
|
from ..utils import validate_regex
|
||||||
from ..AppriseLocale import gettext_lazy as _
|
from ..AppriseLocale import gettext_lazy as _
|
||||||
|
from ..attachment.AttachBase import AttachBase
|
||||||
|
|
||||||
IS_USER = re.compile(r'^\s*@?(?P<user>[A-Z0-9_]+)$', re.I)
|
IS_USER = re.compile(r'^\s*@?(?P<user>[A-Z0-9_]+)$', re.I)
|
||||||
|
|
||||||
@ -87,9 +89,6 @@ class NotifyTwitter(NotifyBase):
|
|||||||
# Twitter does have titles when creating a message
|
# Twitter does have titles when creating a message
|
||||||
title_maxlen = 0
|
title_maxlen = 0
|
||||||
|
|
||||||
# Twitter API
|
|
||||||
twitter_api = 'api.twitter.com'
|
|
||||||
|
|
||||||
# Twitter API Reference To Acquire Someone's Twitter ID
|
# Twitter API Reference To Acquire Someone's Twitter ID
|
||||||
twitter_lookup = 'https://api.twitter.com/1.1/users/lookup.json'
|
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 API Reference To Send A Public Tweet
|
||||||
twitter_tweet = 'https://api.twitter.com/1.1/statuses/update.json'
|
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
|
# Twitter is kind enough to return how many more requests we're allowed to
|
||||||
# continue to make within it's header response as:
|
# continue to make within it's header response as:
|
||||||
# X-Rate-Limit-Reset: The epoc time (in seconds) we can expect our
|
# X-Rate-Limit-Reset: The epoc time (in seconds) we can expect our
|
||||||
@ -176,10 +182,15 @@ class NotifyTwitter(NotifyBase):
|
|||||||
'to': {
|
'to': {
|
||||||
'alias_of': 'targets',
|
'alias_of': 'targets',
|
||||||
},
|
},
|
||||||
|
'batch': {
|
||||||
|
'name': _('Batch Mode'),
|
||||||
|
'type': 'bool',
|
||||||
|
'default': True,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
def __init__(self, ckey, csecret, akey, asecret, targets=None,
|
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
|
Initialize Twitter Object
|
||||||
|
|
||||||
@ -217,6 +228,9 @@ class NotifyTwitter(NotifyBase):
|
|||||||
# Set Cache Flag
|
# Set Cache Flag
|
||||||
self.cache = cache
|
self.cache = cache
|
||||||
|
|
||||||
|
# Prepare Image Batch Mode Flag
|
||||||
|
self.batch = batch
|
||||||
|
|
||||||
if self.mode not in TWITTER_MESSAGE_MODES:
|
if self.mode not in TWITTER_MESSAGE_MODES:
|
||||||
msg = 'The Twitter message mode specified ({}) is invalid.' \
|
msg = 'The Twitter message mode specified ({}) is invalid.' \
|
||||||
.format(mode)
|
.format(mode)
|
||||||
@ -250,42 +264,171 @@ class NotifyTwitter(NotifyBase):
|
|||||||
|
|
||||||
return
|
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
|
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_tweet if the mode is set so
|
||||||
# - calls _send_dm (direct message) otherwise
|
# - calls _send_dm (direct message) otherwise
|
||||||
return getattr(self, '_send_{}'.format(self.mode))(
|
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,
|
def _send_tweet(self, body, title='', notify_type=NotifyType.INFO,
|
||||||
**kwargs):
|
attachments=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
Twitter Public Tweet
|
Twitter Public Tweet
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Error Tracking
|
||||||
|
has_error = False
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
'status': body,
|
'status': body,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Send Tweet
|
payloads = []
|
||||||
postokay, response = self._fetch(
|
if not attachments:
|
||||||
self.twitter_tweet,
|
payloads.append(payload)
|
||||||
payload=payload,
|
|
||||||
json=False,
|
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(
|
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,
|
def _send_dm(self, body, title='', notify_type=NotifyType.INFO,
|
||||||
**kwargs):
|
attachments=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
Twitter Direct Message
|
Twitter Direct Message
|
||||||
"""
|
"""
|
||||||
@ -318,24 +461,48 @@ class NotifyTwitter(NotifyBase):
|
|||||||
'Failed to acquire user(s) to Direct Message via Twitter')
|
'Failed to acquire user(s) to Direct Message via Twitter')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
for screen_name, user_id in targets.items():
|
payloads = []
|
||||||
# Assign our user
|
if not attachments:
|
||||||
payload['event']['message_create']['target']['recipient_id'] = \
|
payloads.append(payload)
|
||||||
user_id
|
|
||||||
|
|
||||||
# Send Twitter DM
|
else:
|
||||||
postokay, response = self._fetch(
|
for no, attachment in enumerate(attachments):
|
||||||
self.twitter_dm,
|
_payload = deepcopy(payload)
|
||||||
payload=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:
|
for no, payload in enumerate(payloads, start=1):
|
||||||
# Track our error
|
for screen_name, user_id in targets.items():
|
||||||
has_error = True
|
# Assign our user
|
||||||
continue
|
target = payload['event']['message_create']['target']
|
||||||
|
target['recipient_id'] = user_id
|
||||||
|
|
||||||
self.logger.info(
|
# Send Twitter DM
|
||||||
'Sent Twitter DM notification to @{}.'.format(screen_name))
|
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
|
return not has_error
|
||||||
|
|
||||||
@ -458,13 +625,23 @@ class NotifyTwitter(NotifyBase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
'Host': self.twitter_api,
|
|
||||||
'User-Agent': self.app_id,
|
'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'
|
headers['Content-Type'] = 'application/json'
|
||||||
payload = dumps(payload)
|
data = dumps(payload)
|
||||||
|
|
||||||
|
else:
|
||||||
|
data = payload
|
||||||
|
|
||||||
auth = OAuth1(
|
auth = OAuth1(
|
||||||
self.ckey,
|
self.ckey,
|
||||||
@ -506,7 +683,8 @@ class NotifyTwitter(NotifyBase):
|
|||||||
try:
|
try:
|
||||||
r = fn(
|
r = fn(
|
||||||
url,
|
url,
|
||||||
data=payload,
|
data=data,
|
||||||
|
files=files,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
verify=self.verify_certificate,
|
verify=self.verify_certificate,
|
||||||
@ -562,6 +740,20 @@ class NotifyTwitter(NotifyBase):
|
|||||||
# Mark our failure
|
# Mark our failure
|
||||||
return (False, content)
|
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)
|
return (True, content)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -581,6 +773,8 @@ class NotifyTwitter(NotifyBase):
|
|||||||
# Define any URL parameters
|
# Define any URL parameters
|
||||||
params = {
|
params = {
|
||||||
'mode': self.mode,
|
'mode': self.mode,
|
||||||
|
'batch': 'yes' if self.batch else 'no',
|
||||||
|
'cache': 'yes' if self.cache else 'no',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extend our parameters
|
# Extend our parameters
|
||||||
@ -653,10 +847,16 @@ class NotifyTwitter(NotifyBase):
|
|||||||
# Store any remaining items as potential targets
|
# Store any remaining items as potential targets
|
||||||
results['targets'].extend(tokens[3:])
|
results['targets'].extend(tokens[3:])
|
||||||
|
|
||||||
|
# Get Cache Flag (reduces lookup hits)
|
||||||
if 'cache' in results['qsd'] and len(results['qsd']['cache']):
|
if 'cache' in results['qsd'] and len(results['qsd']['cache']):
|
||||||
results['cache'] = \
|
results['cache'] = \
|
||||||
parse_bool(results['qsd']['cache'], True)
|
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
|
# 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'] += \
|
||||||
|
@ -23,19 +23,26 @@
|
|||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
|
|
||||||
|
import os
|
||||||
import six
|
import six
|
||||||
import mock
|
import mock
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
from json import dumps
|
from json import dumps
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from apprise import Apprise
|
||||||
from apprise import plugins
|
from apprise import plugins
|
||||||
|
from apprise import NotifyType
|
||||||
|
from apprise import AppriseAttachment
|
||||||
from helpers import AppriseURLTester
|
from helpers import AppriseURLTester
|
||||||
|
|
||||||
# Disable logging for a cleaner testing output
|
# Disable logging for a cleaner testing output
|
||||||
import logging
|
import logging
|
||||||
logging.disable(logging.CRITICAL)
|
logging.disable(logging.CRITICAL)
|
||||||
|
|
||||||
|
# Attachment Directory
|
||||||
|
TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), 'var')
|
||||||
|
|
||||||
# Our Testing URLs
|
# Our Testing URLs
|
||||||
apprise_url_tests = (
|
apprise_url_tests = (
|
||||||
##################################
|
##################################
|
||||||
@ -77,7 +84,9 @@ apprise_url_tests = (
|
|||||||
# However we'll be okay if we return a proper response
|
# However we'll be okay if we return a proper response
|
||||||
'requests_response_text': {
|
'requests_response_text': {
|
||||||
'id': 12345,
|
'id': 12345,
|
||||||
'screen_name': 'test'
|
'screen_name': 'test',
|
||||||
|
# For attachment handling
|
||||||
|
'media_id': 123,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
('twitter://consumer_key/consumer_secret/access_token/access_secret', {
|
('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
|
# However we'll be okay if we return a proper response
|
||||||
'requests_response_text': {
|
'requests_response_text': {
|
||||||
'id': 12345,
|
'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
|
# 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
|
# However we'll be okay if we return a proper response
|
||||||
'requests_response_text': {
|
'requests_response_text': {
|
||||||
'id': 12345,
|
'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
|
# 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
|
# However we'll be okay if we return a proper response
|
||||||
'requests_response_text': {
|
'requests_response_text': {
|
||||||
'id': 12345,
|
'id': 12345,
|
||||||
|
# For attachment handling
|
||||||
|
'media_id': 123,
|
||||||
},
|
},
|
||||||
# due to a mangled response_text we'll fail
|
# due to a mangled response_text we'll fail
|
||||||
'notify_response': False,
|
'notify_response': False,
|
||||||
@ -119,8 +134,8 @@ apprise_url_tests = (
|
|||||||
'notify_response': False,
|
'notify_response': False,
|
||||||
}),
|
}),
|
||||||
('twitter://user@consumer_key/csecret/access_token/access_secret'
|
('twitter://user@consumer_key/csecret/access_token/access_secret'
|
||||||
'?cache=No', {
|
'?cache=No&batch=No', {
|
||||||
# No Cache
|
# No Cache & No Batch
|
||||||
'instance': plugins.NotifyTwitter,
|
'instance': plugins.NotifyTwitter,
|
||||||
'requests_response_text': [{
|
'requests_response_text': [{
|
||||||
'id': 12345,
|
'id': 12345,
|
||||||
@ -404,3 +419,465 @@ def test_plugin_twitter_edge_cases():
|
|||||||
plugins.NotifyTwitter(
|
plugins.NotifyTwitter(
|
||||||
ckey='value', csecret='value', akey='value', asecret='value',
|
ckey='value', csecret='value', akey='value', asecret='value',
|
||||||
targets='%G@rB@g3')
|
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'
|
||||||
|
BIN
test/var/apprise-test.mp4
Normal file
BIN
test/var/apprise-test.mp4
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user