From 4065108b086d7b8f7d0970713cecab78a53f6590 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 23 Nov 2019 22:15:27 -0500 Subject: [PATCH] Telegram Attachment Support (#178) --- apprise/plugins/NotifyTelegram.py | 246 ++++++++++++++++++++--------- test/test_rest_plugins.py | 197 ----------------------- test/test_telegram.py | 252 ++++++++++++++++++++++++++++++ test/var/apprise-test.png | Bin 238810 -> 248280 bytes 4 files changed, 421 insertions(+), 274 deletions(-) create mode 100644 test/test_telegram.py diff --git a/apprise/plugins/NotifyTelegram.py b/apprise/plugins/NotifyTelegram.py index d04bbfd0..62ab7675 100644 --- a/apprise/plugins/NotifyTelegram.py +++ b/apprise/plugins/NotifyTelegram.py @@ -51,6 +51,7 @@ # - https://core.telegram.org/bots/api import requests import re +import os from json import loads from json import dumps @@ -63,6 +64,7 @@ from ..utils import parse_bool from ..utils import parse_list from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ +from ..attachment.AttachBase import AttachBase TELEGRAM_IMAGE_XY = NotifyImageSize.XY_256 @@ -100,12 +102,71 @@ class NotifyTelegram(NotifyBase): # The maximum allowable characters allowed in the body per message body_maxlen = 4096 + # Telegram is limited to sending a maximum of 100 requests per second. + request_rate_per_sec = 0.001 + # Define object templates templates = ( '{schema}://{bot_token}', '{schema}://{bot_token}/{targets}', ) + # Telegram Attachment Support + mime_lookup = ( + # This list is intentionally ordered so that it can be scanned + # from top to bottom. The last entry is a catch-all + + # Animations are documented to only support gif or H.264/MPEG-4 + # Source: https://core.telegram.org/bots/api#sendanimation + { + 'regex': re.compile(r'^(image/gif|video/H264)', re.I), + 'function_name': 'sendAnimation', + 'key': 'animation', + }, + + # This entry is intentially placed below the sendAnimiation allowing + # it to catch gif files. This then becomes a catch all to remaining + # image types. + # Source: https://core.telegram.org/bots/api#sendphoto + { + 'regex': re.compile(r'^image/.*', re.I), + 'function_name': 'sendPhoto', + 'key': 'photo', + }, + + # Video is documented to only support .mp4 + # Source: https://core.telegram.org/bots/api#sendvideo + { + 'regex': re.compile(r'^video/mp4', re.I), + 'function_name': 'sendVideo', + 'key': 'video', + }, + + # Voice supports ogg + # Source: https://core.telegram.org/bots/api#sendvoice + { + 'regex': re.compile(r'^(application|audio)/ogg', re.I), + 'function_name': 'sendVoice', + 'key': 'voice', + }, + + # Audio supports mp3 and m4a only + # Source: https://core.telegram.org/bots/api#sendaudio + { + 'regex': re.compile(r'^audio/(mpeg|mp4a-latm)', re.I), + 'function_name': 'sendAudio', + 'key': 'audio', + }, + + # Catch All (all other types) + # Source: https://core.telegram.org/bots/api#senddocument + { + 'regex': re.compile(r'.*', re.I), + 'function_name': 'sendDocument', + 'key': 'document', + }, + ) + # Define our template tokens template_tokens = dict(NotifyBase.template_tokens, **{ 'bot_token': { @@ -189,82 +250,101 @@ class NotifyTelegram(NotifyBase): # or not. self.include_image = include_image - def send_image(self, chat_id, notify_type): + def send_media(self, chat_id, notify_type, attach=None): """ Sends a sticker based on the specified notify type """ - # The URL; we do not set headers because the api doesn't seem to like - # when we set one. + # Prepare our Headers + headers = { + 'User-Agent': self.app_id, + } + + # Our function name and payload are determined on the path + function_name = 'SendPhoto' + key = 'photo' + path = None + + if isinstance(attach, AttachBase): + # Store our path to our file + path = attach.path + file_name = attach.name + mimetype = attach.mimetype + + if not path: + # Could not load attachment + return False + + # Process our attachment + function_name, key = \ + next(((x['function_name'], x['key']) for x in self.mime_lookup + if x['regex'].match(mimetype))) # pragma: no cover + + else: + attach = self.image_path(notify_type) if attach is None else attach + if attach is None: + # Nothing specified to send + return True + + # Take on specified attachent as path + path = attach + file_name = os.path.basename(path) + url = '%s%s/%s' % ( self.notify_url, self.bot_token, - 'sendPhoto' + function_name, ) - # Acquire our image path if configured to do so; we don't bother - # checking to see if selfinclude_image is set here because the - # send_image() function itself (this function) checks this flag - # already - path = self.image_path(notify_type) - - if not path: - # No image to send - self.logger.debug( - 'Telegram image does not exist for %s' % (notify_type)) - - # No need to fail; we may have been configured this way through - # the apprise.AssetObject() - return True + # Always call throttle before any remote server i/o is made; + # Telegram throttles to occur before sending the image so that + # content can arrive together. + self.throttle() try: with open(path, 'rb') as f: # Configure file payload (for upload) - files = { - 'photo': f, - } - - payload = { - 'chat_id': chat_id, - } + files = {key: (file_name, f)} + payload = {'chat_id': chat_id} self.logger.debug( - 'Telegram image POST URL: %s (cert_verify=%r)' % ( + 'Telegram attachment POST URL: %s (cert_verify=%r)' % ( url, self.verify_certificate)) - try: - r = requests.post( - url, - files=files, - data=payload, - verify=self.verify_certificate, - ) + r = requests.post( + url, + headers=headers, + files=files, + data=payload, + verify=self.verify_certificate, + ) - if r.status_code != requests.codes.ok: - # We had a problem - status_str = NotifyTelegram\ - .http_response_code_lookup(r.status_code) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = NotifyTelegram\ + .http_response_code_lookup(r.status_code) - self.logger.warning( - 'Failed to send Telegram image: ' - '{}{}error={}.'.format( - status_str, - ', ' if status_str else '', - r.status_code)) - - self.logger.debug( - 'Response Details:\r\n{}'.format(r.content)) - - return False - - except requests.RequestException as e: self.logger.warning( - 'A connection error occured posting Telegram image.') - self.logger.debug('Socket Exception: %s' % str(e)) + 'Failed to send Telegram attachment: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + return False - return True + # Content was sent successfully if we got here + return True + + except requests.RequestException as e: + self.logger.warning( + 'A connection error occured posting Telegram ' + 'attachment.') + self.logger.debug('Socket Exception: %s' % str(e)) except (IOError, OSError): # IOError is present for backwards compatibility with Python @@ -364,26 +444,20 @@ class NotifyTelegram(NotifyBase): # Load our response and attempt to fetch our userid response = loads(r.content) - if 'ok' in response and response['ok'] is True: - start = re.compile(r'^\s*\/start', re.I) - for _msg in iter(response['result']): - # Find /start - if not start.search(_msg['message']['text']): - continue - - _id = _msg['message']['from'].get('id', 0) - _user = _msg['message']['from'].get('first_name') - self.logger.info('Detected telegram user %s (userid=%d)' % ( - _user, _id)) - # Return our detected userid - return _id - - self.logger.warning( - 'Could not detect bot owner. Is it running (/start)?') + if 'ok' in response and response['ok'] is True \ + and 'result' in response and len(response['result']): + entry = response['result'][0] + _id = entry['message']['from'].get('id', 0) + _user = entry['message']['from'].get('first_name') + self.logger.info('Detected telegram user %s (userid=%d)' % ( + _user, _id)) + # Return our detected userid + return _id return 0 - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): """ Perform Telegram Notification """ @@ -476,15 +550,20 @@ class NotifyTelegram(NotifyBase): # ID payload['chat_id'] = int(chat_id.group('idno')) + if self.include_image is True: + # Define our path + if not self.send_media(payload['chat_id'], notify_type): + # We failed to send the image associated with our + notify_type + self.logger.warning( + 'Failed to send Telegram type image to {}.', + payload['chat_id']) + # Always call throttle before any remote server i/o is made; # Telegram throttles to occur before sending the image so that # content can arrive together. self.throttle() - if self.include_image is True: - # Send an image - self.send_image(payload['chat_id'], notify_type) - self.logger.debug('Telegram POST URL: %s (cert_verify=%r)' % ( url, self.verify_certificate, )) @@ -524,9 +603,6 @@ class NotifyTelegram(NotifyBase): has_error = True continue - else: - self.logger.info('Sent Telegram notification.') - except requests.RequestException as e: self.logger.warning( 'A connection error occured sending Telegram:%s ' % ( @@ -538,6 +614,22 @@ class NotifyTelegram(NotifyBase): has_error = True continue + self.logger.info('Sent Telegram notification.') + + if attach: + # Send our attachments now (if specified and if it exists) + for attachment in attach: + sent_attachment = self.send_media( + payload['chat_id'], notify_type, attach=attachment) + + if not sent_attachment: + # We failed; don't continue + has_error = True + break + + self.logger.info( + 'Sent Telegram attachment: {}.'.format(attachment)) + return not has_error def url(self, privacy=False, *args, **kwargs): diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index a5a45fdd..65971678 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -5114,203 +5114,6 @@ def test_notify_rocketchat_plugin(mock_post, mock_get): assert obj.logout() is False -@mock.patch('requests.get') -@mock.patch('requests.post') -def test_notify_telegram_plugin(mock_post, mock_get): - """ - API: NotifyTelegram() Extra Checks - - """ - # Disable Throttling to speed testing - plugins.NotifyBase.request_rate_per_sec = 0 - - # Bot Token - bot_token = '123456789:abcdefg_hijklmnop' - invalid_bot_token = 'abcd:123' - - # Chat ID - chat_ids = 'l2g, lead2gold' - - # Prepare Mock - mock_get.return_value = requests.Request() - mock_post.return_value = requests.Request() - mock_post.return_value.status_code = requests.codes.ok - mock_get.return_value.status_code = requests.codes.ok - mock_get.return_value.content = '{}' - mock_post.return_value.content = '{}' - - # Exception should be thrown about the fact no bot token was specified - with pytest.raises(TypeError): - plugins.NotifyTelegram(bot_token=None, targets=chat_ids) - - # Exception should be thrown about the fact an invalid bot token was - # specifed - with pytest.raises(TypeError): - plugins.NotifyTelegram(bot_token=invalid_bot_token, targets=chat_ids) - - obj = plugins.NotifyTelegram( - bot_token=bot_token, targets=chat_ids, include_image=True) - assert isinstance(obj, plugins.NotifyTelegram) is True - assert len(obj.targets) == 2 - - # Test Image Sending Exceptions - mock_get.side_effect = IOError() - mock_post.side_effect = IOError() - obj.send_image(obj.targets[0], NotifyType.INFO) - - # Restore their entries - mock_get.side_effect = None - mock_post.side_effect = None - mock_get.return_value.content = '{}' - mock_post.return_value.content = '{}' - - # test url call - assert isinstance(obj.url(), six.string_types) is True - - # test privacy version of url - assert isinstance(obj.url(privacy=True), six.string_types) is True - assert obj.url(privacy=True).startswith('tgram://1...p/') is True - - # Test that we can load the string we generate back: - obj = plugins.NotifyTelegram(**plugins.NotifyTelegram.parse_url(obj.url())) - assert isinstance(obj, plugins.NotifyTelegram) is True - - # Prepare Mock to fail - response = mock.Mock() - response.status_code = requests.codes.internal_server_error - - # a error response - response.content = dumps({ - 'description': 'test', - }) - mock_get.return_value = response - mock_post.return_value = response - - # No image asset - nimg_obj = plugins.NotifyTelegram(bot_token=bot_token, targets=chat_ids) - nimg_obj.asset = AppriseAsset(image_path_mask=False, image_url_mask=False) - - # Test that our default settings over-ride base settings since they are - # not the same as the one specified in the base; this check merely - # ensures our plugin inheritance is working properly - assert obj.body_maxlen == plugins.NotifyTelegram.body_maxlen - - # We don't override the title maxlen so we should be set to the same - # as our parent class in this case - assert obj.title_maxlen == plugins.NotifyBase.title_maxlen - - # This tests erroneous messages involving multiple chat ids - assert obj.notify( - body='body', title='title', notify_type=NotifyType.INFO) is False - assert obj.notify( - body='body', title='title', notify_type=NotifyType.INFO) is False - assert nimg_obj.notify( - body='body', title='title', notify_type=NotifyType.INFO) is False - - # This tests erroneous messages involving a single chat id - obj = plugins.NotifyTelegram(bot_token=bot_token, targets='l2g') - nimg_obj = plugins.NotifyTelegram(bot_token=bot_token, targets='l2g') - nimg_obj.asset = AppriseAsset(image_path_mask=False, image_url_mask=False) - - assert obj.notify( - body='body', title='title', notify_type=NotifyType.INFO) is False - assert nimg_obj.notify( - body='body', title='title', notify_type=NotifyType.INFO) is False - - # Bot Token Detection - # Just to make it clear to people reading this code and trying to learn - # what is going on. Apprise tries to detect the bot owner if you don't - # specify a user to message. The idea is to just default to messaging - # the bot owner himself (it makes it easier for people). So we're testing - # the creating of a Telegram Notification without providing a chat ID. - # We're testing the error handling of this bot detection section of the - # code - mock_post.return_value.content = dumps({ - "ok": True, - "result": [{ - "update_id": 645421321, - "message": { - "message_id": 1, - "from": { - "id": 532389719, - "is_bot": False, - "first_name": "Chris", - "language_code": "en-US" - }, - "chat": { - "id": 532389719, - "first_name": "Chris", - "type": "private" - }, - "date": 1519694394, - "text": "/start", - "entities": [{ - "offset": 0, - "length": 6, - "type": "bot_command", - }], - }}, - ], - }) - mock_post.return_value.status_code = requests.codes.ok - - obj = plugins.NotifyTelegram(bot_token=bot_token, targets=None) - assert len(obj.targets) == 1 - assert obj.targets[0] == '532389719' - - # Do the test again, but without the expected (parsed response) - mock_post.return_value.content = dumps({ - "ok": True, - "result": [{ - "message": { - "text": "/ignored.entry", - }}, - ], - }) - - # Exception should be thrown about the fact no bot token was specified - with pytest.raises(TypeError): - plugins.NotifyTelegram(bot_token=bot_token, targets=None) - - # Detect the bot with a bad response - mock_post.return_value.content = dumps({}) - obj.detect_bot_owner() - - # Test our bot detection with a internal server error - mock_post.return_value.status_code = requests.codes.internal_server_error - - # Exception should be thrown over internal server error caused - with pytest.raises(TypeError): - plugins.NotifyTelegram(bot_token=bot_token, targets=None) - - # Test our bot detection with an unmappable html error - mock_post.return_value.status_code = 999 - # Exception should be thrown over invali internal error no - with pytest.raises(TypeError): - plugins.NotifyTelegram(bot_token=bot_token, targets=None) - - # Do it again but this time provide a failure message - mock_post.return_value.content = dumps({'description': 'Failure Message'}) - - # Exception should be thrown about the fact no bot token was specified - with pytest.raises(TypeError): - plugins.NotifyTelegram(bot_token=bot_token, targets=None) - - # Do it again but this time provide a failure message and perform a - # notification without a bot detection by providing at least 1 chat id - obj = plugins.NotifyTelegram(bot_token=bot_token, targets=['@abcd']) - assert nimg_obj.notify( - body='body', title='title', notify_type=NotifyType.INFO) is False - - # iterate over our exceptions and test them - for _exception in REQUEST_EXCEPTIONS: - mock_post.side_effect = _exception - - # No chat_ids specified - with pytest.raises(TypeError): - obj = plugins.NotifyTelegram(bot_token=bot_token, targets=None) - - def test_notify_overflow_truncate(): """ API: Overflow Truncate Functionality Testing diff --git a/test/test_telegram.py b/test/test_telegram.py new file mode 100644 index 00000000..d5a25783 --- /dev/null +++ b/test/test_telegram.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +# +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import os +import six +import pytest +import mock +import requests +from json import dumps +from apprise import AppriseAttachment +from apprise import AppriseAsset +from apprise import NotifyType +from apprise import plugins + +# 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') + + +@mock.patch('requests.get') +@mock.patch('requests.post') +def test_notify_telegram_plugin(mock_post, mock_get, tmpdir): + """ + API: NotifyTelegram() Tests + + """ + # Disable Throttling to speed testing + plugins.NotifyBase.request_rate_per_sec = 0 + + # Bot Token + bot_token = '123456789:abcdefg_hijklmnop' + invalid_bot_token = 'abcd:123' + + # Chat ID + chat_ids = 'l2g, lead2gold' + + # Prepare Mock + mock_get.return_value = requests.Request() + mock_post.return_value = requests.Request() + mock_post.return_value.status_code = requests.codes.ok + mock_get.return_value.status_code = requests.codes.ok + mock_get.return_value.content = '{}' + mock_post.return_value.content = '{}' + + # Exception should be thrown about the fact no bot token was specified + with pytest.raises(TypeError): + plugins.NotifyTelegram(bot_token=None, targets=chat_ids) + + # Exception should be thrown about the fact an invalid bot token was + # specifed + with pytest.raises(TypeError): + plugins.NotifyTelegram(bot_token=invalid_bot_token, targets=chat_ids) + + obj = plugins.NotifyTelegram( + bot_token=bot_token, targets=chat_ids, include_image=True) + assert isinstance(obj, plugins.NotifyTelegram) is True + assert len(obj.targets) == 2 + + # Test Image Sending Exceptions + mock_post.side_effect = IOError() + assert not obj.send_media(obj.targets[0], NotifyType.INFO) + + # Test our other objects + mock_post.side_effect = requests.HTTPError + assert not obj.send_media(obj.targets[0], NotifyType.INFO) + + # Restore their entries + mock_get.side_effect = None + mock_post.side_effect = None + mock_get.return_value.content = '{}' + mock_post.return_value.content = '{}' + + # test url call + assert isinstance(obj.url(), six.string_types) is True + + # test privacy version of url + assert isinstance(obj.url(privacy=True), six.string_types) is True + assert obj.url(privacy=True).startswith('tgram://1...p/') is True + + # Test that we can load the string we generate back: + obj = plugins.NotifyTelegram(**plugins.NotifyTelegram.parse_url(obj.url())) + assert isinstance(obj, plugins.NotifyTelegram) is True + + # Prepare Mock to fail + response = mock.Mock() + response.status_code = requests.codes.internal_server_error + + # a error response + response.content = dumps({ + 'description': 'test', + }) + mock_get.return_value = response + mock_post.return_value = response + + # No image asset + nimg_obj = plugins.NotifyTelegram(bot_token=bot_token, targets=chat_ids) + nimg_obj.asset = AppriseAsset(image_path_mask=False, image_url_mask=False) + + # Test that our default settings over-ride base settings since they are + # not the same as the one specified in the base; this check merely + # ensures our plugin inheritance is working properly + assert obj.body_maxlen == plugins.NotifyTelegram.body_maxlen + + # We don't override the title maxlen so we should be set to the same + # as our parent class in this case + assert obj.title_maxlen == plugins.NotifyBase.title_maxlen + + # This tests erroneous messages involving multiple chat ids + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is False + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is False + assert nimg_obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is False + + # This tests erroneous messages involving a single chat id + obj = plugins.NotifyTelegram(bot_token=bot_token, targets='l2g') + nimg_obj = plugins.NotifyTelegram(bot_token=bot_token, targets='l2g') + nimg_obj.asset = AppriseAsset(image_path_mask=False, image_url_mask=False) + + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is False + assert nimg_obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is False + + # Bot Token Detection + # Just to make it clear to people reading this code and trying to learn + # what is going on. Apprise tries to detect the bot owner if you don't + # specify a user to message. The idea is to just default to messaging + # the bot owner himself (it makes it easier for people). So we're testing + # the creating of a Telegram Notification without providing a chat ID. + # We're testing the error handling of this bot detection section of the + # code + mock_post.return_value.content = dumps({ + "ok": True, + "result": [{ + "update_id": 645421321, + "message": { + "message_id": 1, + "from": { + "id": 532389719, + "is_bot": False, + "first_name": "Chris", + "language_code": "en-US" + }, + "chat": { + "id": 532389719, + "first_name": "Chris", + "type": "private" + }, + "date": 1519694394, + "text": "/start", + "entities": [{ + "offset": 0, + "length": 6, + "type": "bot_command", + }], + }}, + ], + }) + mock_post.return_value.status_code = requests.codes.ok + + # Test sending attachments + obj = plugins.NotifyTelegram(bot_token=bot_token, targets='12345') + assert len(obj.targets) == 1 + assert obj.targets[0] == '12345' + + path = os.path.join(TEST_VAR_DIR, 'apprise-test.gif') + attach = AppriseAttachment(path) + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is True + + 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 + + obj = plugins.NotifyTelegram(bot_token=bot_token, targets=None) + assert len(obj.targets) == 1 + assert obj.targets[0] == '532389719' + + # Do the test again, but without the expected (parsed response) + mock_post.return_value.content = dumps({ + "ok": True, + "result": [], + }) + + # Exception should be thrown about the fact no bot token was specified + with pytest.raises(TypeError): + plugins.NotifyTelegram(bot_token=bot_token, targets=None) + + # Detect the bot with a bad response + mock_post.return_value.content = dumps({}) + obj.detect_bot_owner() + + # Test our bot detection with a internal server error + mock_post.return_value.status_code = requests.codes.internal_server_error + + # Exception should be thrown over internal server error caused + with pytest.raises(TypeError): + plugins.NotifyTelegram(bot_token=bot_token, targets=None) + + # Test our bot detection with an unmappable html error + mock_post.return_value.status_code = 999 + # Exception should be thrown over invali internal error no + with pytest.raises(TypeError): + plugins.NotifyTelegram(bot_token=bot_token, targets=None) + + # Do it again but this time provide a failure message + mock_post.return_value.content = dumps({'description': 'Failure Message'}) + + # Exception should be thrown about the fact no bot token was specified + with pytest.raises(TypeError): + plugins.NotifyTelegram(bot_token=bot_token, targets=None) + + # Do it again but this time provide a failure message and perform a + # notification without a bot detection by providing at least 1 chat id + obj = plugins.NotifyTelegram(bot_token=bot_token, targets=['@abcd']) + assert nimg_obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is False + + # iterate over our exceptions and test them + mock_post.side_effect = requests.HTTPError + + # No chat_ids specified + with pytest.raises(TypeError): + obj = plugins.NotifyTelegram(bot_token=bot_token, targets=None) diff --git a/test/var/apprise-test.png b/test/var/apprise-test.png index 6703efe851c3100bda8eeec18bf61117b3536260..9a1ffc10d4f76a4f069ff70c389e15361036bb6d 100644 GIT binary patch delta 10030 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 EqI&to#{d8T delta 458 zcmV;*0X6>ElMmX-4v-^%0fcEoLr_UWLm+T+Z)Rz1WdHyuk$sUnNW(xJ#=ky9sTBn~ zh&W`ZP8NJ2j#`BxR0y>~s}3fYzMx4%lH%ehxE36IELI&{oON|@6$HTth>NR}qKlOH zzogJ2#)IR2e0Sf+-FJY$US_Hp90OF%GE#}SkjbtJov-Ld2tka0qfcU{J|~Jvc#f}o z`1pDk<5}M4{v16@&SZd3Af9EqVG(Z-Pj6Z}=Y8S`D@qFSIq|4L7bJeH9Aql&6gzCYu#!g-6cTCTF@J^2g6Ic;T`>okXuz#^6) zLV%1aN+`oZj8=_*6cZ`hk9+tB9luB}nOr3>ax9<%6_Voz|AXJ%n)%5IHz^zkI$mu1 zV;Jb%1sXNm{yw(t#t9I32ClT0zfuQgK1r{&wCEAgyA51iw={VVxZD8-o^;8O94SE4 zpU(sDXY@^3p#K&Kt+~B5_i_3Fq^Yaq4RCM>j1(w)-Q(ROUG2U7d#2gn4@i)5mL%TS z