* API 4.8

* Elaborate docs

* Address review

* Fix Message.to_json/dict() test

* More coverage

* Update telegram/bot.py

Co-authored-by: Noam Meltzer <tsnoam@gmail.com>

Co-authored-by: Noam Meltzer <tsnoam@gmail.com>
This commit is contained in:
Bibo-Joshi 2020-05-02 11:56:52 +02:00 committed by GitHub
parent ae17ce977e
commit c7c56ad24e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 341 additions and 45 deletions

View file

@ -93,7 +93,7 @@ make the development of bots easy and straightforward. These classes are contain
Telegram API support
====================
All types and methods of the Telegram Bot API **4.7** are supported.
All types and methods of the Telegram Bot API **4.8** are supported.
==========
Installing

View file

@ -3633,6 +3633,10 @@ class Bot(TelegramObject):
reply_to_message_id=None,
reply_markup=None,
timeout=None,
explanation=None,
explanation_parse_mode=DEFAULT_NONE,
open_period=None,
close_date=None,
**kwargs):
"""
Use this method to send a native poll.
@ -3650,6 +3654,18 @@ class Bot(TelegramObject):
answers, ignored for polls in quiz mode, defaults to False.
correct_option_id (:obj:`int`, optional): 0-based identifier of the correct answer
option, required for polls in quiz mode.
explanation (:obj:`str`, optional): Text that is shown when a user chooses an incorrect
answer or taps on the lamp icon in a quiz-style poll, 0-200 characters with at most
2 line feeds after entities parsing.
explanation_parse_mode (:obj:`str`, optional): Mode for parsing entities in the
explanation. See the constants in :class:`telegram.ParseMode` for the available
modes.
open_period (:obj:`int`, optional): Amount of time in seconds the poll will be active
after creation, 5-600. Can't be used together with :attr:`close_date`.
close_date (:obj:`int` | :obj:`datetime.datetime`, optional): Point in time (Unix
timestamp) when the poll will be automatically closed. Must be at least 5 and no
more than 600 seconds in the future. Can't be used together with
:attr:`open_period`.
is_closed (:obj:`bool`, optional): Pass True, if the poll needs to be immediately
closed. This can be useful for poll preview.
disable_notification (:obj:`bool`, optional): Sends the message silently. Users will
@ -3679,6 +3695,12 @@ class Bot(TelegramObject):
'options': options
}
if explanation_parse_mode == DEFAULT_NONE:
if self.defaults:
explanation_parse_mode = self.defaults.parse_mode
else:
explanation_parse_mode = None
if not is_anonymous:
data['is_anonymous'] = is_anonymous
if type:
@ -3689,6 +3711,16 @@ class Bot(TelegramObject):
data['correct_option_id'] = correct_option_id
if is_closed:
data['is_closed'] = is_closed
if explanation:
data['explanation'] = explanation
if explanation_parse_mode:
data['explanation_parse_mode'] = explanation_parse_mode
if open_period:
data['open_period'] = open_period
if close_date:
if isinstance(close_date, datetime):
close_date = to_timestamp(close_date)
data['close_date'] = close_date
return self._message(url, data, timeout=timeout, disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id, reply_markup=reply_markup,
@ -3749,6 +3781,7 @@ class Bot(TelegramObject):
reply_to_message_id=None,
reply_markup=None,
timeout=None,
emoji=None,
**kwargs):
"""
Use this method to send a dice, which will have a random value from 1 to 6. On success, the
@ -3756,6 +3789,8 @@ class Bot(TelegramObject):
Args:
chat_id (:obj:`int` | :obj:`str`): Unique identifier for the target private chat.
emoji (:obj:`str`, optional): Emoji on which the dice throw animation is based.
Currently, must be one of 🎲 or 🎯. Defaults to 🎲
disable_notification (:obj:`bool`, optional): Sends the message silently. Users will
receive a notification with no sound.
reply_to_message_id (:obj:`int`, optional): If the message is a reply, ID of the
@ -3781,6 +3816,9 @@ class Bot(TelegramObject):
'chat_id': chat_id,
}
if emoji:
data['emoji'] = emoji
return self._message(url, data, timeout=timeout, disable_notification=disable_notification,
reply_to_message_id=reply_to_message_id, reply_markup=reply_markup,
**kwargs)

View file

@ -23,17 +23,26 @@ from telegram import TelegramObject
class Dice(TelegramObject):
"""
This object represents a dice with random value from 1 to 6. (The singular form of "dice" is
"die". However, PTB mimics the Telegram API, which uses the term "dice".)
This object represents a dice with random value from 1 to 6 for currently supported base eomji.
(The singular form of "dice" is "die". However, PTB mimics the Telegram API, which uses the
term "dice".)
Note:
If :attr:`emoji` is "🎯", a value of 6 currently represents a bullseye, while a value of 1
indicates that the dartboard was missed. However, this behaviour is undocumented and might
be changed by Telegram.
Attributes:
value (:obj:`int`): Value of the dice.
emoji (:obj:`str`): Emoji on which the dice throw animation is based.
Args:
value (:obj:`int`): Value of the dice, 1-6.
emoji (:obj:`str`): Emoji on which the dice throw animation is based.
"""
def __init__(self, value, **kwargs):
def __init__(self, value, emoji, **kwargs):
self.value = value
self.emoji = emoji
@classmethod
def de_json(cls, data, bot):
@ -41,3 +50,11 @@ class Dice(TelegramObject):
return None
return cls(**data)
DICE = '🎲'
""":obj:`str`: '🎲'"""
DARTS = '🎯'
""":obj:`str`: '🎯'"""
ALL_EMOJI = [DICE, DARTS]
"""List[:obj:`str`]: List of all supported base emoji. Currently :attr:`DICE` and
:attr:`DARTS`."""

View file

@ -215,6 +215,38 @@ class MergedFilter(BaseFilter):
self.and_filter or self.or_filter)
class _DiceEmoji(BaseFilter):
def __init__(self, emoji=None, name=None):
self.name = 'Filters.dice.{}'.format(name) if name else 'Filters.dice'
self.emoji = emoji
class _DiceValues(BaseFilter):
def __init__(self, values, name, emoji=None):
self.values = [values] if isinstance(values, int) else values
self.emoji = emoji
self.name = '{}({})'.format(name, values)
def filter(self, message):
if bool(message.dice and message.dice.value in self.values):
if self.emoji:
return message.dice.emoji == self.emoji
return True
def __call__(self, update):
if isinstance(update, Update):
return self.filter(update.effective_message)
else:
return self._DiceValues(update, self.name, emoji=self.emoji)
def filter(self, message):
if bool(message.dice):
if self.emoji:
return message.dice.emoji == self.emoji
return True
class Filters(object):
"""Predefined filters for use as the `filter` argument of :class:`telegram.ext.MessageHandler`.
@ -967,26 +999,9 @@ officedocument.wordprocessingml.document")``-
poll = _Poll()
"""Messages that contain a :class:`telegram.Poll`."""
class _Dice(BaseFilter):
name = 'Filters.dice'
class _DiceValues(BaseFilter):
def __init__(self, values):
self.values = [values] if isinstance(values, int) else values
self.name = 'Filters.dice({})'.format(values)
def filter(self, message):
return bool(message.dice and message.dice.value in self.values)
def __call__(self, update):
if isinstance(update, Update):
return self.filter(update.effective_message)
else:
return self._DiceValues(update)
def filter(self, message):
return bool(message.dice)
class _Dice(_DiceEmoji):
dice = _DiceEmoji('🎲', 'dice')
darts = _DiceEmoji('🎯', 'darts')
dice = _Dice()
"""Dice Messages. If an integer or a list of integers is passed, it filters messages to only
@ -1007,6 +1022,12 @@ officedocument.wordprocessingml.document")``-
Note:
Dice messages don't have text. If you want to filter either text or dice messages, use
``Filters.text | Filters.dice``.
Attributes:
dice: Dice messages with the emoji 🎲. Passing a list of integers is supported just as for
:attr:`Filters.dice`.
darts: Dice messages with the emoji 🎯. Passing a list of integers is supported just as for
:attr:`Filters.dice`.
"""
class language(BaseFilter):

View file

@ -19,7 +19,10 @@
# along with this program. If not, see [http://www.gnu.org/licenses/].
"""This module contains an object that represents a Telegram Poll."""
from telegram import (TelegramObject, User)
import sys
from telegram import (TelegramObject, User, MessageEntity)
from telegram.utils.helpers import to_timestamp, from_timestamp
class PollOption(TelegramObject):
@ -95,6 +98,14 @@ class Poll(TelegramObject):
type (:obj:`str`): Poll type, currently can be :attr:`REGULAR` or :attr:`QUIZ`.
allows_multiple_answers (:obj:`bool`): True, if the poll allows multiple answers.
correct_option_id (:obj:`int`): Optional. Identifier of the correct answer option.
explanation (:obj:`str`): Optional. Text that is shown when a user chooses an incorrect
answer or taps on the lamp icon in a quiz-style poll.
explanation_entities (List[:class:`telegram.MessageEntity`]): Optional. Special entities
like usernames, URLs, bot commands, etc. that appear in the :attr:`explanation`.
open_period (:obj:`int`): Optional. Amount of time in seconds the poll will be active
after creation.
close_date (:obj:`datetime.datetime`): Optional. Point in time when the poll will be
automatically closed.
Args:
id (:obj:`str`): Unique poll identifier.
@ -107,11 +118,32 @@ class Poll(TelegramObject):
correct_option_id (:obj:`int`, optional): 0-based identifier of the correct answer option.
Available only for polls in the quiz mode, which are closed, or was sent (not
forwarded) by the bot or to the private chat with the bot.
explanation (:obj:`str`, optional): Text that is shown when a user chooses an incorrect
answer or taps on the lamp icon in a quiz-style poll, 0-200 characters.
explanation_entities (List[:class:`telegram.MessageEntity`], optional): Special entities
like usernames, URLs, bot commands, etc. that appear in the :attr:`explanation`.
open_period (:obj:`int`, optional): Amount of time in seconds the poll will be active
after creation.
close_date (:obj:`datetime.datetime`, optional): Point in time (Unix timestamp) when the
poll will be automatically closed. Converted to :obj:`datetime.datetime`.
"""
def __init__(self, id, question, options, total_voter_count, is_closed, is_anonymous, type,
allows_multiple_answers, correct_option_id=None, **kwargs):
def __init__(self,
id,
question,
options,
total_voter_count,
is_closed,
is_anonymous,
type,
allows_multiple_answers,
correct_option_id=None,
explanation=None,
explanation_entities=None,
open_period=None,
close_date=None,
**kwargs):
self.id = id
self.question = question
self.options = options
@ -121,6 +153,10 @@ class Poll(TelegramObject):
self.type = type
self.allows_multiple_answers = allows_multiple_answers
self.correct_option_id = correct_option_id
self.explanation = explanation
self.explanation_entities = explanation_entities
self.open_period = open_period
self.close_date = close_date
self._id_attrs = (self.id,)
@ -132,6 +168,8 @@ class Poll(TelegramObject):
data = super(Poll, cls).de_json(data, bot)
data['options'] = [PollOption.de_json(option, bot) for option in data['options']]
data['explanation_entities'] = MessageEntity.de_list(data.get('explanation_entities'), bot)
data['close_date'] = from_timestamp(data.get('close_date'))
return cls(**data)
@ -139,9 +177,66 @@ class Poll(TelegramObject):
data = super(Poll, self).to_dict()
data['options'] = [x.to_dict() for x in self.options]
if self.explanation_entities:
data['explanation_entities'] = [e.to_dict() for e in self.explanation_entities]
data['close_date'] = to_timestamp(data.get('close_date'))
return data
def parse_explanation_entity(self, entity):
"""Returns the text from a given :class:`telegram.MessageEntity`.
Note:
This method is present because Telegram calculates the offset and length in
UTF-16 codepoint pairs, which some versions of Python don't handle automatically.
(That is, you can't just slice ``Message.text`` with the offset and length.)
Args:
entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must
be an entity that belongs to this message.
Returns:
:obj:`str`: The text of the given entity.
"""
# Is it a narrow build, if so we don't need to convert
if sys.maxunicode == 0xffff:
return self.explanation[entity.offset:entity.offset + entity.length]
else:
entity_text = self.explanation.encode('utf-16-le')
entity_text = entity_text[entity.offset * 2:(entity.offset + entity.length) * 2]
return entity_text.decode('utf-16-le')
def parse_explanation_entities(self, types=None):
"""
Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`.
It contains entities from this polls explanation filtered by their ``type`` attribute as
the key, and the text that each entity belongs to as the value of the :obj:`dict`.
Note:
This method should always be used instead of the :attr:`explanation_entities`
attribute, since it calculates the correct substring from the message text based on
UTF-16 codepoints. See :attr:`parse_explanation_entity` for more info.
Args:
types (List[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the
``type`` attribute of an entity is contained in this list, it will be returned.
Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`.
Returns:
Dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to
the text that belongs to them, calculated based on UTF-16 codepoints.
"""
if types is None:
types = MessageEntity.ALL_TYPES
return {
entity: self.parse_explanation_entity(entity)
for entity in self.explanation_entities if entity.type in types
}
REGULAR = "regular"
""":obj:`str`: 'regular'"""
QUIZ = "quiz"

View file

@ -27,7 +27,7 @@ from future.utils import string_types
from telegram import (Bot, Update, ChatAction, TelegramError, User, InlineKeyboardMarkup,
InlineKeyboardButton, InlineQueryResultArticle, InputTextMessageContent,
ShippingOption, LabeledPrice, ChatPermissions, Poll, BotCommand,
InlineQueryResultDocument)
InlineQueryResultDocument, Dice, MessageEntity, ParseMode)
from telegram.error import BadRequest, InvalidToken, NetworkError, RetryAfter
from telegram.utils.helpers import from_timestamp, escape_markdown
from tests.conftest import expect_bad_request
@ -214,18 +214,86 @@ class TestBot(object):
assert poll.question == question
assert poll.total_voter_count == 0
explanation = '[Here is a link](https://google.com)'
explanation_entities = [
MessageEntity(MessageEntity.TEXT_LINK, 0, 14, url='https://google.com')
]
message_quiz = bot.send_poll(chat_id=super_group_id, question=question, options=answers,
type=Poll.QUIZ, correct_option_id=2, is_closed=True)
type=Poll.QUIZ, correct_option_id=2, is_closed=True,
explanation=explanation,
explanation_parse_mode=ParseMode.MARKDOWN_V2)
assert message_quiz.poll.correct_option_id == 2
assert message_quiz.poll.type == Poll.QUIZ
assert message_quiz.poll.is_closed
assert message_quiz.poll.explanation == 'Here is a link'
assert message_quiz.poll.explanation_entities == explanation_entities
@flaky(3, 1)
@pytest.mark.timeout(10)
def test_send_dice(self, bot, chat_id):
message = bot.send_dice(chat_id)
@pytest.mark.parametrize(['open_period', 'close_date'], [(5, None), (None, True)])
def test_send_open_period(self, bot, super_group_id, open_period, close_date):
question = 'Is this a test?'
answers = ['Yes', 'No', 'Maybe']
reply_markup = InlineKeyboardMarkup.from_button(
InlineKeyboardButton(text='text', callback_data='data'))
if close_date:
close_date = dtm.datetime.utcnow() + dtm.timedelta(seconds=5)
message = bot.send_poll(chat_id=super_group_id, question=question, options=answers,
is_anonymous=False, allows_multiple_answers=True, timeout=60,
open_period=open_period, close_date=close_date)
time.sleep(5.1)
new_message = bot.edit_message_reply_markup(chat_id=super_group_id,
message_id=message.message_id,
reply_markup=reply_markup, timeout=60)
assert new_message.poll.id == message.poll.id
assert new_message.poll.is_closed
@flaky(3, 1)
@pytest.mark.timeout(10)
@pytest.mark.parametrize('default_bot', [{'parse_mode': 'Markdown'}], indirect=True)
def test_send_poll_default_parse_mode(self, default_bot, super_group_id):
explanation = 'Italic Bold Code'
explanation_markdown = '_Italic_ *Bold* `Code`'
question = 'Is this a test?'
answers = ['Yes', 'No', 'Maybe']
message = default_bot.send_poll(chat_id=super_group_id, question=question, options=answers,
type=Poll.QUIZ, correct_option_id=2, is_closed=True,
explanation=explanation_markdown)
assert message.poll.explanation == explanation
assert message.poll.explanation_entities == [
MessageEntity(MessageEntity.ITALIC, 0, 6),
MessageEntity(MessageEntity.BOLD, 7, 4),
MessageEntity(MessageEntity.CODE, 12, 4)
]
message = default_bot.send_poll(chat_id=super_group_id, question=question, options=answers,
type=Poll.QUIZ, correct_option_id=2, is_closed=True,
explanation=explanation_markdown,
explanation_parse_mode=None)
assert message.poll.explanation == explanation_markdown
assert message.poll.explanation_entities == []
message = default_bot.send_poll(chat_id=super_group_id, question=question, options=answers,
type=Poll.QUIZ, correct_option_id=2, is_closed=True,
explanation=explanation_markdown,
explanation_parse_mode='HTML')
assert message.poll.explanation == explanation_markdown
assert message.poll.explanation_entities == []
@flaky(3, 1)
@pytest.mark.timeout(10)
@pytest.mark.parametrize('emoji', Dice.ALL_EMOJI + [None])
def test_send_dice(self, bot, chat_id, emoji):
message = bot.send_dice(chat_id, emoji=emoji)
assert message.dice
if emoji is None:
assert message.dice.emoji == Dice.DICE
else:
assert message.dice.emoji == emoji
@flaky(3, 1)
@pytest.mark.timeout(10)

View file

@ -22,19 +22,22 @@ import pytest
from telegram import Dice
@pytest.fixture(scope="class")
def dice():
return Dice(value=5)
@pytest.fixture(scope="class",
params=Dice.ALL_EMOJI)
def dice(request):
return Dice(value=5, emoji=request.param)
class TestDice(object):
value = 4
def test_de_json(self, bot):
json_dict = {'value': self.value}
@pytest.mark.parametrize('emoji', Dice.ALL_EMOJI)
def test_de_json(self, bot, emoji):
json_dict = {'value': self.value, 'emoji': emoji}
dice = Dice.de_json(json_dict, bot)
assert dice.value == self.value
assert dice.emoji == emoji
assert Dice.de_json(None, bot) is None
def test_to_dict(self, dice):
@ -42,3 +45,4 @@ class TestDice(object):
assert isinstance(dice_dict, dict)
assert dice_dict['value'] == dice.value
assert dice_dict['emoji'] == dice.emoji

View file

@ -622,22 +622,37 @@ class TestFilters(object):
update.message.poll = 'test'
assert Filters.poll(update)
def test_filters_dice(self, update):
update.message.dice = Dice(4)
@pytest.mark.parametrize('emoji', Dice.ALL_EMOJI)
def test_filters_dice(self, update, emoji):
update.message.dice = Dice(4, emoji)
assert Filters.dice(update)
update.message.dice = None
assert not Filters.dice(update)
def test_filters_dice_iterable(self, update):
@pytest.mark.parametrize('emoji', Dice.ALL_EMOJI)
def test_filters_dice_list(self, update, emoji):
update.message.dice = None
assert not Filters.dice(5)(update)
update.message.dice = Dice(5)
update.message.dice = Dice(5, emoji)
assert Filters.dice(5)(update)
assert Filters.dice({5, 6})(update)
assert not Filters.dice(1)(update)
assert not Filters.dice([2, 3])(update)
def test_filters_dice_type(self, update):
update.message.dice = Dice(5, '🎲')
assert Filters.dice.dice(update)
assert Filters.dice.dice([4, 5])(update)
assert not Filters.dice.darts(update)
assert not Filters.dice.dice([6])(update)
update.message.dice = Dice(5, '🎯')
assert Filters.dice.darts(update)
assert Filters.dice.darts([4, 5])(update)
assert not Filters.dice.dice(update)
assert not Filters.dice.darts([6])(update)
def test_language_filter_single(self, update):
update.message.from_user.language_code = 'en_US'
assert (Filters.language('en_US'))(update)

View file

@ -92,13 +92,13 @@ def message(bot):
options=[PollOption(text='a', voter_count=1),
PollOption(text='b', voter_count=2)], is_closed=False,
total_voter_count=0, is_anonymous=False, type=Poll.REGULAR,
allows_multiple_answers=True)},
allows_multiple_answers=True, explanation_entities=[])},
{'text': 'a text message', 'reply_markup': {'inline_keyboard': [[{
'text': 'start', 'url': 'http://google.com'}, {
'text': 'next', 'callback_data': 'abcd'}],
[{'text': 'Cancel', 'callback_data': 'Cancel'}]]}},
{'quote': True},
{'dice': Dice(4)}
{'dice': Dice(4, '🎲')}
],
ids=['forwarded_user', 'forwarded_channel', 'reply', 'edited', 'text',
'caption_entities', 'audio', 'document', 'animation', 'game', 'photo',

View file

@ -19,7 +19,9 @@
import pytest
from telegram import Poll, PollOption, PollAnswer, User
from datetime import datetime
from telegram import Poll, PollOption, PollAnswer, User, MessageEntity
from telegram.utils.helpers import to_timestamp
@pytest.fixture(scope="class")
@ -91,7 +93,11 @@ def poll():
TestPoll.is_closed,
TestPoll.is_anonymous,
TestPoll.type,
TestPoll.allows_multiple_answers
TestPoll.allows_multiple_answers,
explanation=TestPoll.explanation,
explanation_entities=TestPoll.explanation_entities,
open_period=TestPoll.open_period,
close_date=TestPoll.close_date,
)
@ -104,6 +110,11 @@ class TestPoll(object):
is_anonymous = False
type = Poll.REGULAR
allows_multiple_answers = True
explanation = (b'\\U0001f469\\u200d\\U0001f469\\u200d\\U0001f467'
b'\\u200d\\U0001f467\\U0001f431http://google.com').decode('unicode-escape')
explanation_entities = [MessageEntity(13, 17, MessageEntity.URL)]
open_period = 42
close_date = datetime.utcnow()
def test_de_json(self):
json_dict = {
@ -114,7 +125,11 @@ class TestPoll(object):
'is_closed': self.is_closed,
'is_anonymous': self.is_anonymous,
'type': self.type,
'allows_multiple_answers': self.allows_multiple_answers
'allows_multiple_answers': self.allows_multiple_answers,
'explanation': self.explanation,
'explanation_entities': [self.explanation_entities[0].to_dict()],
'open_period': self.open_period,
'close_date': to_timestamp(self.close_date)
}
poll = Poll.de_json(json_dict, None)
@ -130,6 +145,11 @@ class TestPoll(object):
assert poll.is_anonymous == self.is_anonymous
assert poll.type == self.type
assert poll.allows_multiple_answers == self.allows_multiple_answers
assert poll.explanation == self.explanation
assert poll.explanation_entities == self.explanation_entities
assert poll.open_period == self.open_period
assert pytest.approx(poll.close_date == self.close_date)
assert to_timestamp(poll.close_date) == to_timestamp(self.close_date)
def test_to_dict(self, poll):
poll_dict = poll.to_dict()
@ -143,3 +163,21 @@ class TestPoll(object):
assert poll_dict['is_anonymous'] == poll.is_anonymous
assert poll_dict['type'] == poll.type
assert poll_dict['allows_multiple_answers'] == poll.allows_multiple_answers
assert poll_dict['explanation'] == poll.explanation
assert poll_dict['explanation_entities'] == [poll.explanation_entities[0].to_dict()]
assert poll_dict['open_period'] == poll.open_period
assert poll_dict['close_date'] == to_timestamp(poll.close_date)
def test_parse_entity(self, poll):
entity = MessageEntity(type=MessageEntity.URL, offset=13, length=17)
poll.explanation_entities = [entity]
assert poll.parse_explanation_entity(entity) == 'http://google.com'
def test_parse_entities(self, poll):
entity = MessageEntity(type=MessageEntity.URL, offset=13, length=17)
entity_2 = MessageEntity(type=MessageEntity.BOLD, offset=13, length=1)
poll.explanation_entities = [entity_2, entity]
assert poll.parse_explanation_entities(MessageEntity.URL) == {entity: 'http://google.com'}
assert poll.parse_explanation_entities() == {entity: 'http://google.com', entity_2: 'h'}