Telegram Attachment Support (#178)

This commit is contained in:
Chris Caron 2019-11-23 22:15:27 -05:00 committed by GitHub
parent cc8fae4c73
commit 4065108b08
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 421 additions and 274 deletions

View File

@ -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):

View File

@ -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

252
test/test_telegram.py Normal file
View File

@ -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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 242 KiB