diff --git a/README.rst b/README.rst index dc4fcab7d..27f2113e9 100644 --- a/README.rst +++ b/README.rst @@ -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.6** are supported. +All types and methods of the Telegram Bot API **4.7** are supported. ========== Installing diff --git a/docs/source/telegram.botcommand.rst b/docs/source/telegram.botcommand.rst new file mode 100644 index 000000000..9ceb402c5 --- /dev/null +++ b/docs/source/telegram.botcommand.rst @@ -0,0 +1,6 @@ +telegram.BotCommand +=================== + +.. autoclass:: telegram.BotCommand + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.dice.rst b/docs/source/telegram.dice.rst new file mode 100644 index 000000000..6e80f6d48 --- /dev/null +++ b/docs/source/telegram.dice.rst @@ -0,0 +1,6 @@ +telegram.Dice +============= + +.. autoclass:: telegram.Dice + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.rst b/docs/source/telegram.rst index 718308b9e..ddb87d773 100644 --- a/docs/source/telegram.rst +++ b/docs/source/telegram.rst @@ -9,6 +9,7 @@ telegram package telegram.animation telegram.audio telegram.bot + telegram.botcommand telegram.callbackquery telegram.chat telegram.chataction @@ -17,6 +18,7 @@ telegram package telegram.chatphoto telegram.constants telegram.contact + telegram.dice telegram.document telegram.error telegram.file diff --git a/telegram/__init__.py b/telegram/__init__.py index bd22c4804..50ea1027e 100644 --- a/telegram/__init__.py +++ b/telegram/__init__.py @@ -19,6 +19,7 @@ """A library that provides a Python interface to the Telegram Bot API""" from .base import TelegramObject +from .botcommand import BotCommand from .user import User from .files.chatphoto import ChatPhoto from .chat import Chat @@ -36,6 +37,7 @@ from .files.location import Location from .files.venue import Venue from .files.videonote import VideoNote from .chataction import ChatAction +from .dice import Dice from .userprofilephotos import UserProfilePhotos from .keyboardbutton import KeyboardButton from .keyboardbuttonpolltype import KeyboardButtonPollType @@ -157,5 +159,6 @@ __all__ = [ 'InputMediaAudio', 'InputMediaDocument', 'TelegramDecryptionError', 'PassportElementErrorSelfie', 'PassportElementErrorTranslationFile', 'PassportElementErrorTranslationFiles', 'PassportElementErrorUnspecified', 'Poll', - 'PollOption', 'PollAnswer', 'LoginUrl', 'KeyboardButton', 'KeyboardButtonPollType', + 'PollOption', 'PollAnswer', 'LoginUrl', 'KeyboardButton', 'KeyboardButtonPollType', 'Dice', + 'BotCommand' ] diff --git a/telegram/bot.py b/telegram/bot.py index 1589b9419..4c6cde5ee 100644 --- a/telegram/bot.py +++ b/telegram/bot.py @@ -39,7 +39,7 @@ from future.utils import string_types from telegram import (User, Message, Update, Chat, ChatMember, UserProfilePhotos, File, ReplyMarkup, TelegramObject, WebhookInfo, GameHighScore, StickerSet, PhotoSize, Audio, Document, Sticker, Video, Animation, Voice, VideoNote, - Location, Venue, Contact, InputFile, Poll) + Location, Venue, Contact, InputFile, Poll, BotCommand) from telegram.error import InvalidToken, TelegramError from telegram.utils.helpers import to_timestamp, DEFAULT_NONE from telegram.utils.request import Request @@ -53,6 +53,9 @@ def info(func): if not self.bot: self.get_me() + if self._commands is None: + self.get_my_commands() + result = func(self, *args, **kwargs) return result @@ -141,6 +144,7 @@ class Bot(TelegramObject): self.base_url = str(base_url) + str(self.token) self.base_file_url = str(base_file_url) + str(self.token) self.bot = None + self._commands = None self._request = request or Request() self.logger = logging.getLogger(__name__) @@ -159,7 +163,7 @@ class Bot(TelegramObject): if reply_markup is not None: if isinstance(reply_markup, ReplyMarkup): - data['reply_markup'] = reply_markup.to_json() + data['reply_markup'] = reply_markup.to_dict() else: data['reply_markup'] = reply_markup @@ -251,6 +255,13 @@ class Bot(TelegramObject): return self.bot.supports_inline_queries + @property + @info + def commands(self): + """List[:class:`BotCommand`]: Bot's commands.""" + + return self._commands + @property def name(self): """:obj:`str`: Bot's @username.""" @@ -344,6 +355,8 @@ class Bot(TelegramObject): limitations: - A message can only be deleted if it was sent less than 48 hours ago. + - A dice message in a private chat can only be deleted if it was sent more than 24 + hours ago. - Bots can delete outgoing messages in private chats, groups, and supergroups. - Bots can delete incoming messages in private chats. - Bots granted can_post_messages permissions can delete outgoing messages in channels. @@ -3308,15 +3321,23 @@ class Bot(TelegramObject): return File.de_json(result, self) @log - def create_new_sticker_set(self, user_id, name, title, png_sticker, emojis, - contains_masks=None, mask_position=None, timeout=20, **kwargs): + def create_new_sticker_set(self, user_id, name, title, emojis, png_sticker=None, + contains_masks=None, mask_position=None, timeout=20, + tgs_sticker=None, **kwargs): """Use this method to create new sticker set owned by a user. The bot will be able to edit the created sticker set. + You must use exactly one of the fields :attr:`png_sticker` or :attr:`tgs_sticker`. + + Warning: + As of API 4.7 ``png_sticker`` is an optional argument and therefore the order of the + arguments had to be changed. Use keyword arguments to make sure that the arguments are + passed correctly. + Note: - The png_sticker argument can be either a file_id, an URL or a file from disk - ``open(filename, 'rb')`` + The png_sticker and tgs_sticker argument can be either a file_id, an URL or a file from + disk ``open(filename, 'rb')`` Args: user_id (:obj:`int`): User identifier of created sticker set owner. @@ -3326,12 +3347,16 @@ class Bot(TelegramObject): must end in "_by_". is case insensitive. 1-64 characters. title (:obj:`str`): Sticker set title, 1-64 characters. - png_sticker (:obj:`str` | `filelike object`): Png image with the sticker, must be up - to 512 kilobytes in size, dimensions must not exceed 512px, + png_sticker (:obj:`str` | `filelike object`, optional): Png image with the sticker, + must be up to 512 kilobytes in size, dimensions must not exceed 512px, and either width or height must be exactly 512px. Pass a file_id as a String to send a file that already exists on the Telegram servers, pass an HTTP URL as a String for Telegram to get a file from the Internet, or upload a new one using multipart/form-data. + tgs_sticker (:obj:`str` | `filelike object`, optional): TGS animation with the sticker, + uploaded using multipart/form-data. See + https://core.telegram.org/animated_stickers#technical-requirements for technical + requirements emojis (:obj:`str`): One or more emoji corresponding to the sticker. contains_masks (:obj:`bool`, optional): Pass True, if a set of mask stickers should be created. @@ -3354,13 +3379,19 @@ class Bot(TelegramObject): if InputFile.is_file(png_sticker): png_sticker = InputFile(png_sticker) - data = {'user_id': user_id, 'name': name, 'title': title, 'png_sticker': png_sticker, - 'emojis': emojis} + if InputFile.is_file(tgs_sticker): + tgs_sticker = InputFile(tgs_sticker) + data = {'user_id': user_id, 'name': name, 'title': title, 'emojis': emojis} + + if png_sticker is not None: + data['png_sticker'] = png_sticker + if tgs_sticker is not None: + data['tgs_sticker'] = tgs_sticker if contains_masks is not None: data['contains_masks'] = contains_masks if mask_position is not None: - data['mask_position'] = mask_position + data['mask_position'] = mask_position.to_json() data.update(kwargs) result = self._request.post(url, data, timeout=timeout) @@ -3368,23 +3399,35 @@ class Bot(TelegramObject): return result @log - def add_sticker_to_set(self, user_id, name, png_sticker, emojis, mask_position=None, - timeout=20, **kwargs): - """Use this method to add a new sticker to a set created by the bot. + def add_sticker_to_set(self, user_id, name, emojis, png_sticker=None, mask_position=None, + timeout=20, tgs_sticker=None, **kwargs): + """Use this method to add a new sticker to a set created by the bot. You must use exactly + one of the fields png_sticker or tgs_sticker. Animated stickers can be added to animated + sticker sets and only to them. Animated sticker sets can have up to 50 stickers. Static + sticker sets can have up to 120 stickers. + + Warning: + As of API 4.7 ``png_sticker`` is an optional argument and therefore the order of the + arguments had to be changed. Use keyword arguments to make sure that the arguments are + passed correctly. Note: - The png_sticker argument can be either a file_id, an URL or a file from disk - ``open(filename, 'rb')`` + The png_sticker and tgs_sticker argument can be either a file_id, an URL or a file from + disk ``open(filename, 'rb')`` Args: user_id (:obj:`int`): User identifier of created sticker set owner. name (:obj:`str`): Sticker set name. - png_sticker (:obj:`str` | `filelike object`): Png image with the sticker, must be up - to 512 kilobytes in size, dimensions must not exceed 512px, + png_sticker (:obj:`str` | `filelike object`, optional): Png image with the sticker, + must be up to 512 kilobytes in size, dimensions must not exceed 512px, and either width or height must be exactly 512px. Pass a file_id as a String to send a file that already exists on the Telegram servers, pass an HTTP URL as a String for Telegram to get a file from the Internet, or upload a new one using multipart/form-data. + tgs_sticker (:obj:`str` | `filelike object`, optional): TGS animation with the sticker, + uploaded using multipart/form-data. See + https://core.telegram.org/animated_stickers#technical-requirements for technical + requirements emojis (:obj:`str`): One or more emoji corresponding to the sticker. mask_position (:class:`telegram.MaskPosition`, optional): Position where the mask should beplaced on faces. @@ -3405,10 +3448,17 @@ class Bot(TelegramObject): if InputFile.is_file(png_sticker): png_sticker = InputFile(png_sticker) - data = {'user_id': user_id, 'name': name, 'png_sticker': png_sticker, 'emojis': emojis} + if InputFile.is_file(tgs_sticker): + tgs_sticker = InputFile(tgs_sticker) + data = {'user_id': user_id, 'name': name, 'emojis': emojis} + + if png_sticker is not None: + data['png_sticker'] = png_sticker + if tgs_sticker is not None: + data['tgs_sticker'] = tgs_sticker if mask_position is not None: - data['mask_position'] = mask_position + data['mask_position'] = mask_position.to_json() data.update(kwargs) result = self._request.post(url, data, timeout=timeout) @@ -3470,6 +3520,48 @@ class Bot(TelegramObject): return result + @log + def set_sticker_set_thumb(self, name, user_id, thumb=None, timeout=None, **kwargs): + """Use this method to set the thumbnail of a sticker set. Animated thumbnails can be set + for animated sticker sets only. + + Note: + The thumb can be either a file_id, an URL or a file from disk ``open(filename, 'rb')`` + + Args: + name (:obj:`str`): Sticker set name + user_id (:obj:`int`): User identifier of created sticker set owner. + thumb (:obj:`str` | `filelike object`, optional): A PNG image with the thumbnail, must + be up to 128 kilobytes in size and have width and height exactly 100px, or a TGS + animation with the thumbnail up to 32 kilobytes in size; see + https://core.telegram.org/animated_stickers#technical-requirements for animated sticker + technical requirements. Pass a file_id as a String to send a file that already exists + on the Telegram servers, pass an HTTP URL as a String for Telegram to get a file from + the Internet, or upload a new one using multipart/form-data. + timeout (:obj:`int` | :obj:`float`, optional): If this value is specified, use it as + the read timeout from the server (instead of the one specified during + creation of the connection pool). + **kwargs (:obj:`dict`): Arbitrary keyword arguments. + + Returns: + :obj:`bool`: On success, ``True`` is returned. + + Raises: + :class:`telegram.TelegramError` + + """ + url = '{}/setStickerSetThumb'.format(self.base_url) + + if InputFile.is_file(thumb): + thumb = InputFile(thumb) + + data = {'name': name, 'user_id': user_id, 'thumb': thumb} + data.update(kwargs) + + result = self._request.post(url, data, timeout=timeout) + + return result + @log def set_passport_data_errors(self, user_id, errors, timeout=None, **kwargs): """ @@ -3621,7 +3713,7 @@ class Bot(TelegramObject): if reply_markup: if isinstance(reply_markup, ReplyMarkup): - data['reply_markup'] = reply_markup.to_json() + data['reply_markup'] = reply_markup.to_dict() else: data['reply_markup'] = reply_markup @@ -3629,6 +3721,111 @@ class Bot(TelegramObject): return Poll.de_json(result, self) + @log + def send_dice(self, + chat_id, + disable_notification=None, + reply_to_message_id=None, + reply_markup=None, + timeout=None, + **kwargs): + """ + Use this method to send a dice, which will have a random value from 1 to 6. On success, the + sent Message is returned. + + Args: + chat_id (:obj:`int` | :obj:`str`): Unique identifier for the target private chat. + 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 + original message. + reply_markup (:class:`telegram.ReplyMarkup`, optional): Additional interface options. A + JSON-serialized object for an inline keyboard, custom reply keyboard, instructions + to remove reply keyboard or to force a reply from the user. + timeout (:obj:`int` | :obj:`float`, optional): If this value is specified, use it as + the read timeout from the server (instead of the one specified during creation of + the connection pool). + **kwargs (:obj:`dict`): Arbitrary keyword arguments. + + Returns: + :class:`telegram.Message`: On success, the sent Message is returned. + + Raises: + :class:`telegram.TelegramError` + + """ + url = '{0}/sendDice'.format(self.base_url) + + data = { + 'chat_id': chat_id, + } + + return self._message(url, data, timeout=timeout, disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, reply_markup=reply_markup, + **kwargs) + + @log + def get_my_commands(self, timeout=None, **kwargs): + """ + Use this method to get the current list of the bot's commands. + + Args: + timeout (:obj:`int` | :obj:`float`, optional): If this value is specified, use it as + the read timeout from the server (instead of the one specified during creation of + the connection pool). + **kwargs (:obj:`dict`): Arbitrary keyword arguments. + + Returns: + List[:class:`telegram.BotCommand]`: On success, the commands set for the bot + + Raises: + :class:`telegram.TelegramError` + + """ + url = '{0}/getMyCommands'.format(self.base_url) + + result = self._request.get(url, timeout=timeout) + + self._commands = [BotCommand.de_json(c, self) for c in result] + + return self._commands + + @log + def set_my_commands(self, commands, timeout=None, **kwargs): + """ + Use this method to change the list of the bot's commands. + + Args: + commands (List[:class:`BotCommand` | (:obj:`str`, :obj:`str`)]): A JSON-serialized list + of bot commands to be set as the list of the bot's commands. At most 100 commands + can be specified. + timeout (:obj:`int` | :obj:`float`, optional): If this value is specified, use it as + the read timeout from the server (instead of the one specified during creation of + the connection pool). + **kwargs (:obj:`dict`): Arbitrary keyword arguments. + + Returns: + :obj:`True`: On success + + Raises: + :class:`telegram.TelegramError` + + """ + url = '{0}/setMyCommands'.format(self.base_url) + + cmds = [c if isinstance(c, BotCommand) else BotCommand(c[0], c[1]) for c in commands] + + data = {'commands': [c.to_dict() for c in cmds]} + data.update(kwargs) + + result = self._request.post(url, data, timeout=timeout) + + # Set commands. No need to check for outcome. + # If request failed, we won't come this far + self._commands = commands + + return result + def to_dict(self): data = {'id': self.id, 'username': self.username, 'first_name': self.first_name} @@ -3768,9 +3965,17 @@ class Bot(TelegramObject): """Alias for :attr:`set_sticker_position_in_set`""" deleteStickerFromSet = delete_sticker_from_set """Alias for :attr:`delete_sticker_from_set`""" + setStickerSetThumb = set_sticker_set_thumb + """Alias for :attr:`set_sticker_set_thumb`""" setPassportDataErrors = set_passport_data_errors """Alias for :attr:`set_passport_data_errors`""" sendPoll = send_poll """Alias for :attr:`send_poll`""" stopPoll = stop_poll """Alias for :attr:`stop_poll`""" + sendDice = send_dice + """Alias for :attr:`send_dice`""" + getMyCommands = get_my_commands + """Alias for :attr:`get_my_commands`""" + setMyCommands = set_my_commands + """Alias for :attr:`set_my_commands`""" diff --git a/telegram/botcommand.py b/telegram/botcommand.py new file mode 100644 index 000000000..293a5035c --- /dev/null +++ b/telegram/botcommand.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +# pylint: disable=R0903 +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2020 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object that represents a Telegram Bot Command.""" +from telegram import TelegramObject + + +class BotCommand(TelegramObject): + """ + This object represents a bot command. + + Attributes: + command (:obj:`str`): Text of the command. + description (:obj:`str`): Description of the command. + + Args: + command (:obj:`str`): Text of the command, 1-32 characters. Can contain only lowercase + English letters, digits and underscores. + description (:obj:`str`): Description of the command, 3-256 characters. + """ + def __init__(self, command, description, **kwargs): + self.command = command + self.description = description + + @classmethod + def de_json(cls, data, bot): + if not data: + return None + + return cls(**data) diff --git a/telegram/dice.py b/telegram/dice.py new file mode 100644 index 000000000..b90aeb363 --- /dev/null +++ b/telegram/dice.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +# pylint: disable=R0903 +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2020 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object that represents a Telegram Dice.""" +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".) + + Attributes: + value (:obj:`int`): Value of the dice. + + Args: + value (:obj:`int`): Value of the dice, 1-6. + """ + def __init__(self, value, **kwargs): + self.value = value + + @classmethod + def de_json(cls, data, bot): + if not data: + return None + + return cls(**data) diff --git a/telegram/ext/filters.py b/telegram/ext/filters.py index c02e88822..1b464eb39 100644 --- a/telegram/ext/filters.py +++ b/telegram/ext/filters.py @@ -272,6 +272,10 @@ class Filters(object): ... MessageHandler(Filters.text(buttons), callback_method) + Note: + Dice messages don't have text. If you want to filter either text or dice messages, use + ``Filters.text | Filters.dice``. + Args: update (List[:obj:`str`] | Tuple[:obj:`str`], optional): Which messages to allow. Only exact matches are allowed. If not specified, will allow any text message. @@ -427,7 +431,7 @@ class Filters(object): send media with wrong types that don't fit to this handler. Example: - Filters.documents.category('audio/') returnes `True` for all types + Filters.documents.category('audio/') returns `True` for all types of audio sent as file, for example 'audio/mpeg' or 'audio/x-wav' """ @@ -957,6 +961,48 @@ 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) + + dice = _Dice() + """Dice Messages. If an integer or a list of integers is passed, it filters messages to only + allow those whose dice value is appearing in the given list. + + Examples: + To allow any dice message, simply use + ``MessageHandler(Filters.dice, callback_method)``. + To allow only dice with value 6, use + ``MessageHandler(Filters.dice(6), callback_method)``. + To allow only dice with value 5 `or` 6, use + ``MessageHandler(Filters.dice([5, 6]), callback_method)``. + + Args: + update (:obj:`int` | List[:obj:`int`], optional): Which values to allow. If not + specified, will allow any dice message. + + Note: + Dice messages don't have text. If you want to filter either text or dice messages, use + ``Filters.text | Filters.dice``. + """ + class language(BaseFilter): """Filters messages to only allow those which are from users with a certain language code. diff --git a/telegram/files/sticker.py b/telegram/files/sticker.py index 16e08de7b..8249cbcbe 100644 --- a/telegram/files/sticker.py +++ b/telegram/files/sticker.py @@ -138,6 +138,8 @@ class StickerSet(TelegramObject): is_animated (:obj:`bool`): True, if the sticker set contains animated stickers. contains_masks (:obj:`bool`): True, if the sticker set contains masks. stickers (List[:class:`telegram.Sticker`]): List of all set stickers. + thumb (:class:`telegram.PhotoSize`): Optional. Sticker set thumbnail in the .WEBP or .TGS + format Args: name (:obj:`str`): Sticker set name. @@ -145,15 +147,20 @@ class StickerSet(TelegramObject): is_animated (:obj:`bool`): True, if the sticker set contains animated stickers. contains_masks (:obj:`bool`): True, if the sticker set contains masks. stickers (List[:class:`telegram.Sticker`]): List of all set stickers. + thumb (:class:`telegram.PhotoSize`, optional): Sticker set thumbnail in the .WEBP or .TGS + format """ - def __init__(self, name, title, is_animated, contains_masks, stickers, bot=None, **kwargs): + def __init__(self, name, title, is_animated, contains_masks, stickers, bot=None, thumb=None, + **kwargs): self.name = name self.title = title self.is_animated = is_animated self.contains_masks = contains_masks self.stickers = stickers + # Optionals + self.thumb = thumb self._id_attrs = (self.name,) @@ -164,6 +171,7 @@ class StickerSet(TelegramObject): data = super(StickerSet, StickerSet).de_json(data, bot) + data['thumb'] = PhotoSize.de_json(data.get('thumb'), bot) data['stickers'] = Sticker.de_list(data.get('stickers'), bot) return StickerSet(bot=bot, **data) diff --git a/telegram/message.py b/telegram/message.py index 76c698e90..399d8e8b3 100644 --- a/telegram/message.py +++ b/telegram/message.py @@ -23,7 +23,7 @@ from html import escape from telegram import (Animation, Audio, Contact, Document, Chat, Location, PhotoSize, Sticker, TelegramObject, User, Video, Voice, Venue, MessageEntity, Game, Invoice, - SuccessfulPayment, VideoNote, PassportData, Poll, InlineKeyboardMarkup) + SuccessfulPayment, VideoNote, PassportData, Poll, InlineKeyboardMarkup, Dice) from telegram import ParseMode from telegram.utils.helpers import escape_markdown, to_timestamp, from_timestamp @@ -106,6 +106,7 @@ class Message(TelegramObject): passport_data (:class:`telegram.PassportData`): Optional. Telegram Passport data. poll (:class:`telegram.Poll`): Optional. Message is a native poll, information about the poll. + dice (:class:`telegram.Dice`): Optional. Message is a dice. reply_markup (:class:`telegram.InlineKeyboardMarkup`): Optional. Inline keyboard attached to the message. bot (:class:`telegram.Bot`): Optional. The Bot to use for instance methods. @@ -199,7 +200,7 @@ class Message(TelegramObject): smaller than 52 bits, so a signed 64 bit integer or double-precision float type are safe for storing this identifier. pinned_message (:class:`telegram.message`, optional): Specified message was pinned. Note - that the Message object in this field will not contain further attr:`reply_to_message` + that the Message object in this field will not contain further :attr:`reply_to_message` fields even if it is itself a reply. invoice (:class:`telegram.Invoice`, optional): Message is an invoice for a payment, information about the invoice. @@ -214,6 +215,7 @@ class Message(TelegramObject): passport_data (:class:`telegram.PassportData`, optional): Telegram Passport data. poll (:class:`telegram.Poll`, optional): Message is a native poll, information about the poll. + dice (:class:`telegram.Dice`, optional): Message is a dice with random value from 1 to 6. reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): Inline keyboard attached to the message. login_url buttons are represented as ordinary url buttons. default_quote (:obj:`bool`, optional): Default setting for the `quote` parameter of the @@ -229,7 +231,7 @@ class Message(TelegramObject): MESSAGE_TYPES = ['text', 'new_chat_members', 'left_chat_member', 'new_chat_title', 'new_chat_photo', 'delete_chat_photo', 'group_chat_created', 'supergroup_chat_created', 'channel_chat_created', 'migrate_to_chat_id', - 'migrate_from_chat_id', 'pinned_message', + 'migrate_from_chat_id', 'pinned_message', 'poll', 'dice', 'passport_data'] + ATTACHMENT_TYPES def __init__(self, @@ -282,6 +284,7 @@ class Message(TelegramObject): reply_markup=None, bot=None, default_quote=None, + dice=None, **kwargs): # Required self.message_id = int(message_id) @@ -331,6 +334,7 @@ class Message(TelegramObject): self.animation = animation self.passport_data = passport_data self.poll = poll + self.dice = dice self.reply_markup = reply_markup self.bot = bot self.default_quote = default_quote @@ -404,6 +408,7 @@ class Message(TelegramObject): data['successful_payment'] = SuccessfulPayment.de_json(data.get('successful_payment'), bot) data['passport_data'] = PassportData.de_json(data.get('passport_data'), bot) data['poll'] = Poll.de_json(data.get('poll'), bot) + data['dice'] = Dice.de_json(data.get('dice'), bot) data['reply_markup'] = InlineKeyboardMarkup.de_json(data.get('reply_markup'), bot) return cls(bot=bot, **data) @@ -808,6 +813,23 @@ class Message(TelegramObject): self._quote(kwargs) return self.bot.send_poll(self.chat_id, *args, **kwargs) + def reply_dice(self, *args, **kwargs): + """Shortcut for:: + + bot.send_dice(update.message.chat_id, *args, **kwargs) + + Keyword Args: + quote (:obj:`bool`, optional): If set to ``True``, the dice is sent as an actual reply + to this message. If ``reply_to_message_id`` is passed in ``kwargs``, this parameter + will be ignored. Default: ``True`` in group chats and ``False`` in private chats. + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + self._quote(kwargs) + return self.bot.send_dice(self.chat_id, *args, **kwargs) + def forward(self, chat_id, *args, **kwargs): """Shortcut for:: diff --git a/tests/data/sticker_set_thumb.png b/tests/data/sticker_set_thumb.png new file mode 100644 index 000000000..063e8cc0a Binary files /dev/null and b/tests/data/sticker_set_thumb.png differ diff --git a/tests/data/telegram_animated_sticker.tgs b/tests/data/telegram_animated_sticker.tgs new file mode 100644 index 000000000..c8bf05cde Binary files /dev/null and b/tests/data/telegram_animated_sticker.tgs differ diff --git a/tests/test_bot.py b/tests/test_bot.py index 78f166ece..ee81ed35c 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -26,7 +26,7 @@ from future.utils import string_types from telegram import (Bot, Update, ChatAction, TelegramError, User, InlineKeyboardMarkup, InlineKeyboardButton, InlineQueryResultArticle, InputTextMessageContent, - ShippingOption, LabeledPrice, ChatPermissions, Poll, + ShippingOption, LabeledPrice, ChatPermissions, Poll, BotCommand, InlineQueryResultDocument) from telegram.error import BadRequest, InvalidToken, NetworkError, RetryAfter from telegram.utils.helpers import from_timestamp, escape_markdown @@ -80,6 +80,7 @@ class TestBot(object): @pytest.mark.timeout(10) def test_get_me_and_properties(self, bot): get_me_bot = bot.get_me() + commands = bot.get_my_commands() assert isinstance(get_me_bot, User) assert get_me_bot.id == bot.id @@ -91,6 +92,7 @@ class TestBot(object): assert get_me_bot.can_read_all_group_messages == bot.can_read_all_group_messages assert get_me_bot.supports_inline_queries == bot.supports_inline_queries assert 'https://t.me/{}'.format(get_me_bot.username) == bot.link + assert commands == bot.commands @flaky(3, 1) @pytest.mark.timeout(10) @@ -174,7 +176,13 @@ class TestBot(object): @flaky(3, 1) @pytest.mark.timeout(10) - def test_send_and_stop_poll(self, bot, super_group_id): + @pytest.mark.parametrize('reply_markup', [ + None, + InlineKeyboardMarkup.from_button(InlineKeyboardButton(text='text', callback_data='data')), + InlineKeyboardMarkup.from_button( + InlineKeyboardButton(text='text', callback_data='data')).to_dict() + ]) + def test_send_and_stop_poll(self, bot, super_group_id, reply_markup): question = 'Is this a test?' answers = ['Yes', 'No', 'Maybe'] message = bot.send_poll(chat_id=super_group_id, question=question, options=answers, @@ -190,7 +198,10 @@ class TestBot(object): assert not message.poll.is_closed assert message.poll.type == Poll.REGULAR - poll = bot.stop_poll(chat_id=super_group_id, message_id=message.message_id, timeout=60) + # Since only the poll and not the complete message is returned, we can't check that the + # reply_markup is correct. So we just test that sending doesn't give an error. + poll = bot.stop_poll(chat_id=super_group_id, message_id=message.message_id, + reply_markup=reply_markup, timeout=60) assert isinstance(poll, Poll) assert poll.is_closed assert poll.options[0].text == answers[0] @@ -208,6 +219,13 @@ class TestBot(object): assert message_quiz.poll.type == Poll.QUIZ assert message_quiz.poll.is_closed + @flaky(3, 1) + @pytest.mark.timeout(10) + def test_send_dice(self, bot, chat_id): + message = bot.send_dice(chat_id) + + assert message.dice + @flaky(3, 1) @pytest.mark.timeout(10) def test_send_chat_action(self, bot, chat_id): @@ -904,3 +922,41 @@ class TestBot(object): def test_send_message_default_quote(self, default_bot, chat_id): message = default_bot.send_message(chat_id, 'test') assert message.default_quote is True + + @flaky(3, 1) + @pytest.mark.timeout(10) + def test_set_and_get_my_commands(self, bot): + commands = [ + BotCommand('cmd1', 'descr1'), + BotCommand('cmd2', 'descr2'), + ] + bot.set_my_commands([]) + assert bot.get_my_commands() == [] + assert bot.commands == [] + assert bot.set_my_commands(commands) + + for bc in [bot.get_my_commands(), bot.commands]: + assert len(bc) == 2 + assert bc[0].command == 'cmd1' + assert bc[0].description == 'descr1' + assert bc[1].command == 'cmd2' + assert bc[1].description == 'descr2' + + @flaky(3, 1) + @pytest.mark.timeout(10) + def test_set_and_get_my_commands_strings(self, bot): + commands = [ + ['cmd1', 'descr1'], + ['cmd2', 'descr2'], + ] + bot.set_my_commands([]) + assert bot.get_my_commands() == [] + assert bot.commands == [] + assert bot.set_my_commands(commands) + + for bc in [bot.get_my_commands(), bot.commands]: + assert len(bc) == 2 + assert bc[0].command == 'cmd1' + assert bc[0].description == 'descr1' + assert bc[1].command == 'cmd2' + assert bc[1].description == 'descr2' diff --git a/tests/test_botcommand.py b/tests/test_botcommand.py new file mode 100644 index 000000000..9b339276d --- /dev/null +++ b/tests/test_botcommand.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2020 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + +import pytest + +from telegram import BotCommand + + +@pytest.fixture(scope="class") +def bot_command(): + return BotCommand(command='start', description='A command') + + +class TestBotCommand(object): + command = 'start' + description = 'A command' + + def test_de_json(self, bot): + json_dict = {'command': self.command, 'description': self.description} + bot_command = BotCommand.de_json(json_dict, bot) + + assert bot_command.command == self.command + assert bot_command.description == self.description + + assert BotCommand.de_json(None, bot) is None + + def test_to_dict(self, bot_command): + bot_command_dict = bot_command.to_dict() + + assert isinstance(bot_command_dict, dict) + assert bot_command_dict['command'] == bot_command.command + assert bot_command_dict['description'] == bot_command.description diff --git a/tests/test_dice.py b/tests/test_dice.py new file mode 100644 index 000000000..89805f45f --- /dev/null +++ b/tests/test_dice.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2020 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + +import pytest + +from telegram import Dice + + +@pytest.fixture(scope="class") +def dice(): + return Dice(value=5) + + +class TestDice(object): + value = 4 + + def test_de_json(self, bot): + json_dict = {'value': self.value} + dice = Dice.de_json(json_dict, bot) + + assert dice.value == self.value + assert Dice.de_json(None, bot) is None + + def test_to_dict(self, dice): + dice_dict = dice.to_dict() + + assert isinstance(dice_dict, dict) + assert dice_dict['value'] == dice.value diff --git a/tests/test_filters.py b/tests/test_filters.py index f081fed08..c89615899 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -20,7 +20,7 @@ import datetime import pytest -from telegram import Message, User, Chat, MessageEntity, Document, Update +from telegram import Message, User, Chat, MessageEntity, Document, Update, Dice from telegram.ext import Filters, BaseFilter import re @@ -622,6 +622,22 @@ class TestFilters(object): update.message.poll = 'test' assert Filters.poll(update) + def test_filters_dice(self, update): + update.message.dice = Dice(4) + assert Filters.dice(update) + update.message.dice = None + assert not Filters.dice(update) + + def test_filters_dice_iterable(self, update): + update.message.dice = None + assert not Filters.dice(5)(update) + + update.message.dice = Dice(5) + 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_language_filter_single(self, update): update.message.from_user.language_code = 'en_US' assert (Filters.language('en_US'))(update) diff --git a/tests/test_message.py b/tests/test_message.py index 3900e9768..c37f2ad81 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -22,7 +22,7 @@ import pytest from telegram import (Update, Message, User, MessageEntity, Chat, Audio, Document, Animation, Game, PhotoSize, Sticker, Video, Voice, VideoNote, Contact, Location, Venue, - Invoice, SuccessfulPayment, PassportData, ParseMode, Poll, PollOption) + Invoice, SuccessfulPayment, PassportData, ParseMode, Poll, PollOption, Dice) from tests.test_passport import RAW_PASSPORT_DATA @@ -97,7 +97,8 @@ def message(bot): 'text': 'start', 'url': 'http://google.com'}, { 'text': 'next', 'callback_data': 'abcd'}], [{'text': 'Cancel', 'callback_data': 'Cancel'}]]}}, - {'quote': True} + {'quote': True}, + {'dice': Dice(4)} ], ids=['forwarded_user', 'forwarded_channel', 'reply', 'edited', 'text', 'caption_entities', 'audio', 'document', 'animation', 'game', 'photo', @@ -107,7 +108,7 @@ def message(bot): 'migrated_from', 'pinned', 'invoice', 'successful_payment', 'connected_website', 'forward_signature', 'author_signature', 'photo_from_media_group', 'passport_data', 'poll', 'reply_markup', - 'default_quote']) + 'default_quote', 'dice']) def message_params(bot, request): return Message(message_id=TestMessage.id_, from_user=TestMessage.from_user, @@ -702,7 +703,7 @@ class TestMessage(object): def test_reply_poll(self, monkeypatch, message): def test(*args, **kwargs): id_ = args[0] == message.chat_id - contact = kwargs['contact'] == 'test_poll' + contact = kwargs['question'] == 'test_poll' if kwargs.get('reply_to_message_id'): reply = kwargs['reply_to_message_id'] == message.message_id else: @@ -710,8 +711,22 @@ class TestMessage(object): return id_ and contact and reply monkeypatch.setattr(message.bot, 'send_poll', test) - assert message.reply_poll(contact='test_poll') - assert message.reply_poll(contact='test_poll', quote=True) + assert message.reply_poll(question='test_poll') + assert message.reply_poll(question='test_poll', quote=True) + + def test_reply_dice(self, monkeypatch, message): + def test(*args, **kwargs): + id_ = args[0] == message.chat_id + contact = kwargs['disable_notification'] is True + if kwargs.get('reply_to_message_id'): + reply = kwargs['reply_to_message_id'] == message.message_id + else: + reply = True + return id_ and contact and reply + + monkeypatch.setattr(message.bot, 'send_dice', test) + assert message.reply_dice(disable_notification=True) + assert message.reply_dice(disable_notification=True, quote=True) def test_forward(self, monkeypatch, message): def test(*args, **kwargs): diff --git a/tests/test_sticker.py b/tests/test_sticker.py index 6bc844ffc..acd2b894a 100644 --- a/tests/test_sticker.py +++ b/tests/test_sticker.py @@ -40,6 +40,19 @@ def sticker(bot, chat_id): return bot.send_sticker(chat_id, sticker=f, timeout=50).sticker +@pytest.fixture(scope='function') +def animated_sticker_file(): + f = open('tests/data/telegram_animated_sticker.tgs', 'rb') + yield f + f.close() + + +@pytest.fixture(scope='class') +def animated_sticker(bot, chat_id): + with open('tests/data/telegram_animated_sticker.tgs', 'rb') as f: + return bot.send_sticker(chat_id, sticker=f, timeout=50).sticker + + class TestSticker(object): # sticker_file_url = 'https://python-telegram-bot.org/static/testfiles/telegram.webp' # Serving sticker from gh since our server sends wrong content_type @@ -245,12 +258,27 @@ class TestSticker(object): @pytest.fixture(scope='function') def sticker_set(bot): - ss = bot.get_sticker_set('test_by_{0}'.format(bot.username)) + ss = bot.get_sticker_set('test_by_{}'.format(bot.username)) if len(ss.stickers) > 100: raise Exception('stickerset is growing too large.') return ss +@pytest.fixture(scope='function') +def animated_sticker_set(bot): + ss = bot.get_sticker_set('animated_test_by_{}'.format(bot.username)) + if len(ss.stickers) > 100: + raise Exception('stickerset is growing too large.') + return ss + + +@pytest.fixture(scope='function') +def sticker_set_thumb_file(): + f = open('tests/data/sticker_set_thumb.png', 'rb') + yield f + f.close() + + class TestStickerSet(object): title = 'Test stickers' is_animated = True @@ -258,14 +286,15 @@ class TestStickerSet(object): stickers = [Sticker('file_id', 'file_un_id', 512, 512, True)] name = 'NOTAREALNAME' - def test_de_json(self, bot): - name = 'test_by_{0}'.format(bot.username) + def test_de_json(self, bot, sticker): + name = 'test_by_{}'.format(bot.username) json_dict = { 'name': name, 'title': self.title, 'is_animated': self.is_animated, 'contains_masks': self.contains_masks, - 'stickers': [x.to_dict() for x in self.stickers] + 'stickers': [x.to_dict() for x in self.stickers], + 'thumb': sticker.thumb.to_dict() } sticker_set = StickerSet.de_json(json_dict, bot) @@ -274,15 +303,28 @@ class TestStickerSet(object): assert sticker_set.is_animated == self.is_animated assert sticker_set.contains_masks == self.contains_masks assert sticker_set.stickers == self.stickers + assert sticker_set.thumb == sticker.thumb @flaky(3, 1) @pytest.mark.timeout(10) - def test_bot_methods_1(self, bot, chat_id): + def test_bot_methods_1_png(self, bot, chat_id, sticker_file): with open('tests/data/telegram_sticker.png', 'rb') as f: file = bot.upload_sticker_file(95205500, f) assert file - assert bot.add_sticker_to_set(chat_id, 'test_by_{0}'.format(bot.username), - file.file_id, '😄') + assert bot.add_sticker_to_set(chat_id, 'test_by_{}'.format(bot.username), + png_sticker=file.file_id, emojis='😄') + # Also test with file input and mask + assert bot.add_sticker_to_set(chat_id, 'test_by_{}'.format(bot.username), + png_sticker=sticker_file, emojis='😄', + mask_position=MaskPosition(MaskPosition.EYES, -1, 1, 2)) + + @flaky(3, 1) + @pytest.mark.timeout(10) + def test_bot_methods_1_tgs(self, bot, chat_id): + assert bot.add_sticker_to_set( + chat_id, 'animated_test_by_{}'.format(bot.username), + tgs_sticker=open('tests/data/telegram_animated_sticker.tgs', 'rb'), + emojis='😄') def test_sticker_set_to_dict(self, sticker_set): sticker_set_dict = sticker_set.to_dict() @@ -296,17 +338,48 @@ class TestStickerSet(object): @flaky(3, 1) @pytest.mark.timeout(10) - def test_bot_methods_2(self, bot, sticker_set): + def test_bot_methods_2_png(self, bot, sticker_set): file_id = sticker_set.stickers[0].file_id assert bot.set_sticker_position_in_set(file_id, 1) + @flaky(3, 1) + @pytest.mark.timeout(10) + def test_bot_methods_2_tgs(self, bot, animated_sticker_set): + file_id = animated_sticker_set.stickers[0].file_id + assert bot.set_sticker_position_in_set(file_id, 1) + @flaky(10, 1) @pytest.mark.timeout(10) - def test_bot_methods_3(self, bot, sticker_set): + def test_bot_methods_3_png(self, bot, chat_id, sticker_set_thumb_file): + sleep(1) + assert bot.set_sticker_set_thumb('test_by_{}'.format(bot.username), chat_id, + sticker_set_thumb_file) + + @flaky(10, 1) + @pytest.mark.timeout(10) + def test_bot_methods_3_tgs(self, bot, chat_id, animated_sticker_file, animated_sticker_set): + sleep(1) + assert bot.set_sticker_set_thumb('animated_test_by_{}'.format(bot.username), chat_id, + animated_sticker_file) + file_id = animated_sticker_set.stickers[-1].file_id + # also test with file input and mask + assert bot.set_sticker_set_thumb('animated_test_by_{}'.format(bot.username), chat_id, + file_id) + + @flaky(10, 1) + @pytest.mark.timeout(10) + def test_bot_methods_4_png(self, bot, sticker_set): sleep(1) file_id = sticker_set.stickers[-1].file_id assert bot.delete_sticker_from_set(file_id) + @flaky(10, 1) + @pytest.mark.timeout(10) + def test_bot_methods_4_tgs(self, bot, animated_sticker_set): + sleep(1) + file_id = animated_sticker_set.stickers[-1].file_id + assert bot.delete_sticker_from_set(file_id) + def test_get_file_instance_method(self, monkeypatch, sticker): def test(*args, **kwargs): return args[1] == sticker.file_id