diff --git a/README.rst b/README.rst index abca55dba..c7ea960a9 100644 --- a/README.rst +++ b/README.rst @@ -14,7 +14,7 @@ :target: https://pypi.org/project/python-telegram-bot/ :alt: Supported Python versions -.. image:: https://img.shields.io/badge/Bot%20API-6.0-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-6.1-blue?logo=telegram :target: https://core.telegram.org/bots/api-changelog :alt: Supported Bot API versions @@ -93,7 +93,7 @@ Installing both ``python-telegram-bot`` and ``python-telegram-bot-raw`` in conju Telegram API support ==================== -All types and methods of the Telegram Bot API **6.0** are supported. +All types and methods of the Telegram Bot API **6.1** are supported. Installing ========== diff --git a/README_RAW.rst b/README_RAW.rst index 882ffd470..813139fef 100644 --- a/README_RAW.rst +++ b/README_RAW.rst @@ -14,7 +14,7 @@ :target: https://pypi.org/project/python-telegram-bot-raw/ :alt: Supported Python versions -.. image:: https://img.shields.io/badge/Bot%20API-6.0-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-6.1-blue?logo=telegram :target: https://core.telegram.org/bots/api-changelog :alt: Supported Bot API versions @@ -89,7 +89,7 @@ Installing both ``python-telegram-bot`` and ``python-telegram-bot-raw`` in conju Telegram API support ==================== -All types and methods of the Telegram Bot API **6.0** are supported. +All types and methods of the Telegram Bot API **6.1** are supported. Installing ========== diff --git a/docs/source/bot_methods.rst b/docs/source/bot_methods.rst index c3eea11bb..282c5669b 100644 --- a/docs/source/bot_methods.rst +++ b/docs/source/bot_methods.rst @@ -263,6 +263,8 @@ :align: left :widths: 1 4 + * - :meth:`~telegram.Bot.create_invoice_link` + - Used to generate an HTTP link for an invoice * - :meth:`~telegram.Bot.close` - Used for closing server instance when switching to another local server * - :meth:`~telegram.Bot.log_out` diff --git a/telegram/_bot.py b/telegram/_bot.py index ebc6aa5dd..8f9f9e47d 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -3795,6 +3795,7 @@ class Bot(TelegramObject, AbstractAsyncContextManager): allowed_updates: List[str] = None, ip_address: str = None, drop_pending_updates: bool = None, + secret_token: str = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -3808,9 +3809,9 @@ class Bot(TelegramObject, AbstractAsyncContextManager): specified url, containing An Update. In case of an unsuccessful request, Telegram will give up after a reasonable amount of attempts. - If you'd like to make sure that the Webhook request comes from Telegram, Telegram - recommends using a secret path in the URL, e.g. https://www.example.com/. Since - nobody else knows your bot's token, you can be pretty sure it's them. + If you'd like to make sure that the Webhook was set by you, you can specify secret data in + the parameter :paramref:`secret_token`. If specified, the request will contain a header + ``X-Telegram-Bot-Api-Secret-Token`` with the secret token as content. Note: The certificate argument should be a file from disk ``open(filename, 'rb')``. @@ -3839,6 +3840,14 @@ class Bot(TelegramObject, AbstractAsyncContextManager): a short period of time. drop_pending_updates (:obj:`bool`, optional): Pass :obj:`True` to drop all pending updates. + secret_token (:obj:`str`, optional): A secret token to be sent in a header + ``X-Telegram-Bot-Api-Secret-Token`` in every webhook request, + :tg-const:`telegram.constants.WebhookLimit.MIN_SECRET_TOKEN_LENGTH`- + :tg-const:`telegram.constants.WebhookLimit.MAX_SECRET_TOKEN_LENGTH` characters. + Only characters ``A-Z``, ``a-z``, ``0-9``, ``_`` and ``-`` are allowed. + The header is useful to ensure that the request comes from a webhook set by you. + + .. versionadded:: 20.0 Keyword Args: read_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to @@ -3889,6 +3898,8 @@ class Bot(TelegramObject, AbstractAsyncContextManager): data["ip_address"] = ip_address if drop_pending_updates: data["drop_pending_updates"] = drop_pending_updates + if secret_token is not None: + data["secret_token"] = secret_token result = await self._post( "setWebhook", @@ -4593,26 +4604,32 @@ class Bot(TelegramObject, AbstractAsyncContextManager): Args: chat_id (:obj:`int` | :obj:`str`): Unique identifier for the target chat or username of the target channel (in the format ``@channelusername``). - title (:obj:`str`): Product name, 1-32 characters. - description (:obj:`str`): Product description, 1-255 characters. - payload (:obj:`str`): Bot-defined invoice payload, 1-128 bytes. This will not be + title (:obj:`str`): Product name. :tg-const:`telegram.Invoice.MIN_TITLE_LENGTH`- + :tg-const:`telegram.Invoice.MAX_TITLE_LENGTH` characters. + description (:obj:`str`): Product description. + :tg-const:`telegram.Invoice.MIN_DESCRIPTION_LENGTH`- + :tg-const:`telegram.Invoice.MAX_DESCRIPTION_LENGTH` characters. + payload (:obj:`str`): Bot-defined invoice payload. + :tg-const:`telegram.Invoice.MIN_PAYLOAD_LENGTH`- + :tg-const:`telegram.Invoice.MAX_PAYLOAD_LENGTH` bytes. This will not be displayed to the user, use for your internal processes. provider_token (:obj:`str`): Payments provider token, obtained via `@BotFather `_. - currency (:obj:`str`): Three-letter ISO 4217 currency code. + currency (:obj:`str`): Three-letter ISO 4217 currency code, see `more on currencies + `_. prices (List[:class:`telegram.LabeledPrice`)]: Price breakdown, a list of components (e.g. product price, tax, discount, delivery cost, delivery tax, bonus, etc.). max_tip_amount (:obj:`int`, optional): The maximum accepted amount for tips in the - smallest units of the currency (integer, not float/double). For example, for a - maximum tip of US$ 1.45 pass ``max_tip_amount = 145``. See the exp parameter in + *smallest* units of the currency (integer, **not** float/double). For example, for + a maximum tip of US$ 1.45 pass ``max_tip_amount = 145``. See the exp parameter in `currencies.json `_, it shows the number of digits past the decimal point for each currency (2 for the majority of currencies). Defaults to ``0``. .. versionadded:: 13.5 suggested_tip_amounts (List[:obj:`int`], optional): An array of - suggested amounts of tips in the smallest units of the currency (integer, not + suggested amounts of tips in the *smallest* units of the currency (integer, **not** float/double). At most 4 suggested tip amounts can be specified. The suggested tip amounts must be positive, passed in a strictly increased order and must not exceed ``max_tip_amount``. @@ -7693,6 +7710,159 @@ class Bot(TelegramObject, AbstractAsyncContextManager): ) return MenuButton.de_json(result, bot=self) # type: ignore[return-value, arg-type] + @_log + async def create_invoice_link( + self, + title: str, + description: str, + payload: str, + provider_token: str, + currency: str, + prices: List["LabeledPrice"], + max_tip_amount: int = None, + suggested_tip_amounts: List[int] = None, + provider_data: Union[str, object] = None, + photo_url: str = None, + photo_size: int = None, + photo_width: int = None, + photo_height: int = None, + need_name: bool = None, + need_phone_number: bool = None, + need_email: bool = None, + need_shipping_address: bool = None, + send_phone_number_to_provider: bool = None, + send_email_to_provider: bool = None, + is_flexible: bool = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict = None, + ) -> str: + """Use this method to create a link for an invoice. + + .. versionadded:: 20.0 + + Args: + title (:obj:`str`): Product name. :tg-const:`telegram.Invoice.MIN_TITLE_LENGTH`- + :tg-const:`telegram.Invoice.MAX_TITLE_LENGTH` characters. + description (:obj:`str`): Product description. + :tg-const:`telegram.Invoice.MIN_DESCRIPTION_LENGTH`- + :tg-const:`telegram.Invoice.MAX_DESCRIPTION_LENGTH` characters. + payload (:obj:`str`): Bot-defined invoice payload. + :tg-const:`telegram.Invoice.MIN_PAYLOAD_LENGTH`- + :tg-const:`telegram.Invoice.MAX_PAYLOAD_LENGTH` bytes. This will not be + displayed to the user, use for your internal processes. + provider_token (:obj:`str`): Payments provider token, obtained via + `@BotFather `_. + currency (:obj:`str`): Three-letter ISO 4217 currency code, see `more on currencies + `_. + prices (List[:class:`telegram.LabeledPrice`)]: Price breakdown, a list + of components (e.g. product price, tax, discount, delivery cost, delivery tax, + bonus, etc.). + max_tip_amount (:obj:`int`, optional): The maximum accepted amount for tips in the + *smallest* units of the currency (integer, **not** float/double). For example, for + a maximum tip of US$ 1.45 pass ``max_tip_amount = 145``. See the exp parameter in + `currencies.json `_, it + shows the number of digits past the decimal point for each currency (2 for the + majority of currencies). Defaults to ``0``. + suggested_tip_amounts (List[:obj:`int`], optional): An array of + suggested amounts of tips in the *smallest* units of the currency (integer, **not** + float/double). At most 4 suggested tip amounts can be specified. The suggested tip + amounts must be positive, passed in a strictly increased order and must not exceed + :paramref:`max_tip_amount`. + provider_data (:obj:`str` | :obj:`object`, optional): Data about the + invoice, which will be shared with the payment provider. A detailed description of + required fields should be provided by the payment provider. When an object is + passed, it will be encoded as JSON. + photo_url (:obj:`str`, optional): URL of the product photo for the invoice. Can be a + photo of the goods or a marketing image for a service. + photo_size (:obj:`int`, optional): Photo size in bytes. + photo_width (:obj:`int`, optional): Photo width. + photo_height (:obj:`int`, optional): Photo height. + need_name (:obj:`bool`, optional): Pass :obj:`True`, if you require the user's full + name to complete the order. + need_phone_number (:obj:`bool`, optional): Pass :obj:`True`, if you require the user's + phone number to complete the order. + need_email (:obj:`bool`, optional): Pass :obj:`True`, if you require the user's email + address to complete the order. + need_shipping_address (:obj:`bool`, optional): Pass :obj:`True`, if you require the + user's shipping address to complete the order. + send_phone_number_to_provider (:obj:`bool`, optional): Pass :obj:`True`, if user's + phone number should be sent to provider. + send_email_to_provider (:obj:`bool`, optional): Pass :obj:`True`, if user's email + address should be sent to provider. + is_flexible (:obj:`bool`, optional): Pass :obj:`True`, if the final price depends on + the shipping method. + + Keyword Args: + read_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to + :paramref:`telegram.request.BaseRequest.post.read_timeout`. Defaults to + :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. + write_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to + :paramref:`telegram.request.BaseRequest.post.write_timeout`. Defaults to + :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. + connect_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to + :paramref:`telegram.request.BaseRequest.post.connect_timeout`. Defaults to + :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. + pool_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to + :paramref:`telegram.request.BaseRequest.post.pool_timeout`. Defaults to + :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. + api_kwargs (:obj:`dict`, optional): Arbitrary keyword arguments to be passed to the + Telegram API. + + Returns: + :class:`str`: On success, the created invoice link is returned. + + """ + data: JSONDict = { + "title": title, + "description": description, + "payload": payload, + "provider_token": provider_token, + "currency": currency, + "prices": prices, + } + if max_tip_amount is not None: + data["max_tip_amount"] = max_tip_amount + if suggested_tip_amounts is not None: + data["suggested_tip_amounts"] = suggested_tip_amounts + if provider_data is not None: + data["provider_data"] = provider_data + if photo_url is not None: + data["photo_url"] = photo_url + if photo_size is not None: + data["photo_size"] = photo_size + if photo_width is not None: + data["photo_width"] = photo_width + if photo_height is not None: + data["photo_height"] = photo_height + if need_name is not None: + data["need_name"] = need_name + if need_phone_number is not None: + data["need_phone_number"] = need_phone_number + if need_email is not None: + data["need_email"] = need_email + if need_shipping_address is not None: + data["need_shipping_address"] = need_shipping_address + if is_flexible is not None: + data["is_flexible"] = is_flexible + if send_phone_number_to_provider is not None: + data["send_phone_number_to_provider"] = send_phone_number_to_provider + if send_email_to_provider is not None: + data["send_email_to_provider"] = send_email_to_provider + + return await self._post( # type: ignore[return-value] + "createInvoiceLink", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + def to_dict(self) -> JSONDict: """See :meth:`telegram.TelegramObject.to_dict`.""" data: JSONDict = {"id": self.id, "username": self.username, "first_name": self.first_name} @@ -7881,3 +8051,5 @@ class Bot(TelegramObject, AbstractAsyncContextManager): """Alias for :meth:`get_my_default_administrator_rights`""" setMyDefaultAdministratorRights = set_my_default_administrator_rights """Alias for :meth:`set_my_default_administrator_rights`""" + createInvoiceLink = create_invoice_link + """Alias for :meth:`create_invoice_link`""" diff --git a/telegram/_chat.py b/telegram/_chat.py index f7cf503ec..0b7074deb 100644 --- a/telegram/_chat.py +++ b/telegram/_chat.py @@ -124,6 +124,16 @@ class Chat(TelegramObject): chats. Returned only in :meth:`telegram.Bot.get_chat`. location (:class:`telegram.ChatLocation`, optional): For supergroups, the location to which the supergroup is connected. Returned only in :meth:`telegram.Bot.get_chat`. + join_to_send_messages (:obj:`bool`, optional): :obj:`True`, if users need to join the + supergroup before they can send messages. Returned only in + :meth:`telegram.Bot.get_chat`. + + .. versionadded:: 20.0 + join_by_request (:obj:`bool`, optional): :obj:`True`, if all users directly joining the + supergroup need to be approved by supergroup administrators. Returned only in + :meth:`telegram.Bot.get_chat`. + + .. versionadded:: 20.0 **kwargs (:obj:`dict`): Arbitrary keyword arguments. Attributes: @@ -168,6 +178,16 @@ class Chat(TelegramObject): chats. Returned only in :meth:`telegram.Bot.get_chat`. location (:class:`telegram.ChatLocation`): Optional. For supergroups, the location to which the supergroup is connected. Returned only in :meth:`telegram.Bot.get_chat`. + join_to_send_messages (:obj:`bool`): Optional. :obj:`True`, if users need to join + the supergroup before they can send messages. Returned only in + :meth:`telegram.Bot.get_chat`. + + .. versionadded:: 20.0 + join_by_request (:obj:`bool`): Optional. :obj:`True`, if all users directly + joining the supergroup need to be approved by supergroup administrators. Returned only + in :meth:`telegram.Bot.get_chat`. + + .. versionadded:: 20.0 """ @@ -193,6 +213,8 @@ class Chat(TelegramObject): "message_auto_delete_time", "has_protected_content", "has_private_forwards", + "join_to_send_messages", + "join_by_request", ) SENDER: ClassVar[str] = constants.ChatType.SENDER @@ -232,6 +254,8 @@ class Chat(TelegramObject): message_auto_delete_time: int = None, has_private_forwards: bool = None, has_protected_content: bool = None, + join_to_send_messages: bool = None, + join_by_request: bool = None, **_kwargs: Any, ): # Required @@ -260,6 +284,8 @@ class Chat(TelegramObject): self.can_set_sticker_set = can_set_sticker_set self.linked_chat_id = linked_chat_id self.location = location + self.join_to_send_messages = join_to_send_messages + self.join_by_request = join_by_request self.set_bot(bot) self._id_attrs = (self.id,) diff --git a/telegram/_files/sticker.py b/telegram/_files/sticker.py index e5abe1e59..564e9e5c4 100644 --- a/telegram/_files/sticker.py +++ b/telegram/_files/sticker.py @@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, List, Optional from telegram import constants from telegram._files._basethumbedmedium import _BaseThumbedMedium +from telegram._files.file import File from telegram._files.photosize import PhotoSize from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -62,6 +63,10 @@ class Sticker(_BaseThumbedMedium): position where the mask should be placed. file_size (:obj:`int`, optional): File size in bytes. bot (:class:`telegram.Bot`, optional): The Bot to use for instance methods. + premium_animation (:class:`telegram.File`, optional): Premium animation for the sticker, + if the sticker is premium. + + .. versionadded:: 20.0 _kwargs (:obj:`dict`): Arbitrary keyword arguments. Attributes: @@ -83,6 +88,10 @@ class Sticker(_BaseThumbedMedium): where the mask should be placed. file_size (:obj:`int`): Optional. File size in bytes. bot (:class:`telegram.Bot`): Optional. The Bot to use for instance methods. + premium_animation (:class:`telegram.File`): Optional. Premium animation for the + sticker, if the sticker is premium. + + .. versionadded:: 20.0 """ @@ -94,6 +103,7 @@ class Sticker(_BaseThumbedMedium): "mask_position", "set_name", "width", + "premium_animation", ) def __init__( @@ -110,6 +120,7 @@ class Sticker(_BaseThumbedMedium): set_name: str = None, mask_position: "MaskPosition" = None, bot: "Bot" = None, + premium_animation: "File" = None, **_kwargs: Any, ): super().__init__( @@ -128,6 +139,7 @@ class Sticker(_BaseThumbedMedium): self.emoji = emoji self.set_name = set_name self.mask_position = mask_position + self.premium_animation = premium_animation @classmethod def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Sticker"]: @@ -139,6 +151,7 @@ class Sticker(_BaseThumbedMedium): data["thumb"] = PhotoSize.de_json(data.get("thumb"), bot) data["mask_position"] = MaskPosition.de_json(data.get("mask_position"), bot) + data["premium_animation"] = File.de_json(data.get("premium_animation"), bot) return cls(bot=bot, **data) diff --git a/telegram/_inline/inputinvoicemessagecontent.py b/telegram/_inline/inputinvoicemessagecontent.py index d09530602..1beeb199a 100644 --- a/telegram/_inline/inputinvoicemessagecontent.py +++ b/telegram/_inline/inputinvoicemessagecontent.py @@ -39,9 +39,14 @@ class InputInvoiceMessageContent(InputMessageContent): .. versionadded:: 13.5 Args: - title (:obj:`str`): Product name, 1-32 characters - description (:obj:`str`): Product description, 1-255 characters - payload (:obj:`str`):Bot-defined invoice payload, 1-128 bytes. This will not be displayed + title (:obj:`str`): Product name. :tg-const:`telegram.Invoice.MIN_TITLE_LENGTH`- + :tg-const:`telegram.Invoice.MAX_TITLE_LENGTH` characters. + description (:obj:`str`): Product description. + :tg-const:`telegram.Invoice.MIN_DESCRIPTION_LENGTH`- + :tg-const:`telegram.Invoice.MAX_DESCRIPTION_LENGTH` characters. + payload (:obj:`str`): Bot-defined invoice payload. + :tg-const:`telegram.Invoice.MIN_PAYLOAD_LENGTH`- + :tg-const:`telegram.Invoice.MAX_PAYLOAD_LENGTH` bytes. This will not be displayed to the user, use for your internal processes. provider_token (:obj:`str`): Payment provider token, obtained via `@Botfather `_. @@ -50,15 +55,15 @@ class InputInvoiceMessageContent(InputMessageContent): prices (List[:class:`telegram.LabeledPrice`]): Price breakdown, a list of components (e.g. product price, tax, discount, delivery cost, delivery tax, bonus, etc.) - max_tip_amount (:obj:`int`, optional): The maximum accepted amount for tips in the smallest - units of the currency (integer, not float/double). For example, for a maximum tip of - US$ 1.45 pass ``max_tip_amount = 145``. See the ``exp`` parameter in + max_tip_amount (:obj:`int`, optional): The maximum accepted amount for tips in the + *smallest* units of the currency (integer, **not** float/double). For example, for a + maximum tip of US$ 1.45 pass ``max_tip_amount = 145``. See the ``exp`` parameter in `currencies.json `_, it shows the number of digits past the decimal point for each currency (2 for the majority of currencies). Defaults to ``0``. suggested_tip_amounts (List[:obj:`int`], optional): An array of suggested - amounts of tip in the smallest units of the currency (integer, not float/double). At - most 4 suggested tip amounts can be specified. The suggested tip amounts must be + amounts of tip in the *smallest* units of the currency (integer, **not** float/double). + At most 4 suggested tip amounts can be specified. The suggested tip amounts must be positive, passed in a strictly increased order and must not exceed :attr:`max_tip_amount`. provider_data (:obj:`str`, optional): An object for data about the invoice, @@ -87,9 +92,14 @@ class InputInvoiceMessageContent(InputMessageContent): **kwargs (:obj:`dict`): Arbitrary keyword arguments. Attributes: - title (:obj:`str`): Product name, 1-32 characters - description (:obj:`str`): Product description, 1-255 characters - payload (:obj:`str`):Bot-defined invoice payload, 1-128 bytes. This will not be displayed + title (:obj:`str`): Product name. :tg-const:`telegram.Invoice.MIN_TITLE_LENGTH`- + :tg-const:`telegram.Invoice.MAX_TITLE_LENGTH` characters. + description (:obj:`str`): Product description. + :tg-const:`telegram.Invoice.MIN_DESCRIPTION_LENGTH`- + :tg-const:`telegram.Invoice.MAX_DESCRIPTION_LENGTH` characters. + payload (:obj:`str`): Bot-defined invoice payload. + :tg-const:`telegram.Invoice.MIN_PAYLOAD_LENGTH`- + :tg-const:`telegram.Invoice.MAX_PAYLOAD_LENGTH` bytes. This will not be displayed to the user, use for your internal processes. provider_token (:obj:`str`): Payment provider token, obtained via `@Botfather `_. diff --git a/telegram/_loginurl.py b/telegram/_loginurl.py index 906f3d5b0..261acaa8b 100644 --- a/telegram/_loginurl.py +++ b/telegram/_loginurl.py @@ -39,7 +39,7 @@ class LoginUrl(TelegramObject): `Checking authorization `_ Args: - url (:obj:`str`): An HTTP URL to be opened with user authorization data added to the query + url (:obj:`str`): An HTTPS URL to be opened with user authorization data added to the query string when the button is pressed. If the user refuses to provide authorization data, the original URL without information about the user will be opened. The data added is the same as described in @@ -59,7 +59,7 @@ class LoginUrl(TelegramObject): for your bot to send messages to the user. Attributes: - url (:obj:`str`): An HTTP URL to be opened with user authorization data. + url (:obj:`str`): An HTTPS URL to be opened with user authorization data. forward_text (:obj:`str`): Optional. New text of the button in forwarded messages. bot_username (:obj:`str`): Optional. Username of a bot, which will be used for user authorization. diff --git a/telegram/_payment/invoice.py b/telegram/_payment/invoice.py index 501a9c565..363fdf9a0 100644 --- a/telegram/_payment/invoice.py +++ b/telegram/_payment/invoice.py @@ -18,8 +18,9 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Invoice.""" -from typing import Any +from typing import Any, ClassVar +from telegram import constants from telegram._telegramobject import TelegramObject @@ -83,3 +84,34 @@ class Invoice(TelegramObject): self.currency, self.total_amount, ) + + MIN_TITLE_LENGTH: ClassVar[int] = constants.InvoiceLimit.MIN_TITLE_LENGTH + """:const:`telegram.constants.InvoiceLimit.MIN_TITLE_LENGTH` + + .. versionadded:: 20.0 + """ + MAX_TITLE_LENGTH: ClassVar[int] = constants.InvoiceLimit.MAX_TITLE_LENGTH + """:const:`telegram.constants.InvoiceLimit.MAX_TITLE_LENGTH` + + .. versionadded:: 20.0 + """ + MIN_DESCRIPTION_LENGTH: ClassVar[int] = constants.InvoiceLimit.MIN_DESCRIPTION_LENGTH + """:const:`telegram.constants.InvoiceLimit.MIN_DESCRIPTION_LENGTH` + + .. versionadded:: 20.0 + """ + MAX_DESCRIPTION_LENGTH: ClassVar[int] = constants.InvoiceLimit.MAX_DESCRIPTION_LENGTH + """:const:`telegram.constants.InvoiceLimit.MAX_DESCRIPTION_LENGTH` + + .. versionadded:: 20.0 + """ + MIN_PAYLOAD_LENGTH: ClassVar[int] = constants.InvoiceLimit.MIN_PAYLOAD_LENGTH + """:const:`telegram.constants.InvoiceLimit.MIN_PAYLOAD_LENGTH` + + .. versionadded:: 20.0 + """ + MAX_PAYLOAD_LENGTH: ClassVar[int] = constants.InvoiceLimit.MAX_PAYLOAD_LENGTH + """:const:`telegram.constants.InvoiceLimit.MAX_PAYLOAD_LENGTH` + + .. versionadded:: 20.0 + """ diff --git a/telegram/_user.py b/telegram/_user.py index 8d0093e0a..36c5d8c41 100644 --- a/telegram/_user.py +++ b/telegram/_user.py @@ -83,6 +83,13 @@ class User(TelegramObject): supports_inline_queries (:obj:`str`, optional): :obj:`True`, if the bot supports inline queries. Returned only in :attr:`telegram.Bot.get_me` requests. bot (:class:`telegram.Bot`, optional): The Bot to use for instance methods. + is_premium (:obj:`bool`, optional): :obj:`True`, if this user is a Telegram Premium user. + + .. versionadded:: 20.0 + added_to_attachment_menu (:obj:`bool`, optional): :obj:`True`, if this user added + the bot to the attachment menu. + + .. versionadded:: 20.0 Attributes: id (:obj:`int`): Unique identifier for this user or bot. @@ -98,7 +105,14 @@ class User(TelegramObject): supports_inline_queries (:obj:`str`): Optional. :obj:`True`, if the bot supports inline queries. Returned only in :attr:`telegram.Bot.get_me` requests. bot (:class:`telegram.Bot`): Optional. The Bot to use for instance methods. + is_premium (:obj:`bool`): Optional. :obj:`True`, if this user is a Telegram + Premium user. + .. versionadded:: 20.0 + added_to_attachment_menu (:obj:`bool`): Optional. :obj:`True`, if this user added + the bot to the attachment menu. + + .. versionadded:: 20.0 """ __slots__ = ( @@ -111,6 +125,8 @@ class User(TelegramObject): "supports_inline_queries", "id", "language_code", + "is_premium", + "added_to_attachment_menu", ) def __init__( @@ -125,6 +141,8 @@ class User(TelegramObject): can_read_all_group_messages: bool = None, supports_inline_queries: bool = None, bot: "Bot" = None, + is_premium: bool = None, + added_to_attachment_menu: bool = None, **_kwargs: Any, ): # Required @@ -138,6 +156,8 @@ class User(TelegramObject): self.can_join_groups = can_join_groups self.can_read_all_group_messages = can_read_all_group_messages self.supports_inline_queries = supports_inline_queries + self.is_premium = is_premium + self.added_to_attachment_menu = added_to_attachment_menu self.set_bot(bot) self._id_attrs = (self.id,) diff --git a/telegram/constants.py b/telegram/constants.py index 564118121..f72ca8192 100644 --- a/telegram/constants.py +++ b/telegram/constants.py @@ -45,6 +45,7 @@ __all__ = [ "InlineQueryLimit", "InlineQueryResultType", "InputMediaType", + "InvoiceLimit", "LocationLimit", "MaskPosition", "MenuButtonType", @@ -56,6 +57,7 @@ __all__ = [ "PollLimit", "PollType", "SUPPORTED_WEBHOOK_PORTS", + "WebhookLimit", "UpdateType", ] @@ -90,7 +92,7 @@ class _BotAPIVersion(NamedTuple): #: :data:`telegram.__bot_api_version_info__`. #: #: .. versionadded:: 20.0 -BOT_API_VERSION_INFO = _BotAPIVersion(major=6, minor=0) +BOT_API_VERSION_INFO = _BotAPIVersion(major=6, minor=1) #: :obj:`str`: Telegram Bot API #: version supported by this version of `python-telegram-bot`. Also available as #: :data:`telegram.__bot_api_version__`. @@ -809,3 +811,41 @@ class UpdateType(StringEnum): """:obj:`str`: Updates with :attr:`telegram.Update.chat_member`.""" CHAT_JOIN_REQUEST = "chat_join_request" """:obj:`str`: Updates with :attr:`telegram.Update.chat_join_request`.""" + + +class InvoiceLimit(IntEnum): + """This enum contains limitations for :meth:`telegram.Bot.create_invoice_link`. The enum + members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + MIN_TITLE_LENGTH = 1 + """:obj:`int`: Minimum number of characters of the invoice title.""" + MAX_TITLE_LENGTH = 32 + """:obj:`int`: Maximum number of characters of the invoice title.""" + MIN_DESCRIPTION_LENGTH = 1 + """:obj:`int`: Minimum number of characters of the invoice description.""" + MAX_DESCRIPTION_LENGTH = 255 + """:obj:`int`: Maximum number of characters of the invoice description.""" + MIN_PAYLOAD_LENGTH = 1 + """:obj:`int`: Minimum amount of bytes for the internal payload.""" + MAX_PAYLOAD_LENGTH = 128 + """:obj:`int`: Maximum amount of bytes for the internal payload.""" + + +class WebhookLimit(IntEnum): + """This enum contains limitations for :paramref:`telegram.Bot.set_webhook.secret_token`. The + enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + MIN_SECRET_TOKEN_LENGTH = 1 + """:obj:`int`: Minimum length of the secret token.""" + MAX_SECRET_TOKEN_LENGTH = 256 + """:obj:`int`: Maximum length of the secret token.""" diff --git a/telegram/ext/_application.py b/telegram/ext/_application.py index 6ba64bfd4..4b4e73575 100644 --- a/telegram/ext/_application.py +++ b/telegram/ext/_application.py @@ -662,6 +662,7 @@ class Application(Generic[BT, CCT, UD, CD, BD, JQ], AbstractAsyncContextManager) max_connections: int = 40, close_loop: bool = True, stop_signals: ODVInput[Sequence[int]] = DEFAULT_NONE, + secret_token: str = None, ) -> None: """Convenience method that takes care of initializing and starting the app, polling updates from Telegram using :meth:`telegram.ext.Updater.start_webhook` and @@ -724,6 +725,16 @@ class Application(Generic[BT, CCT, UD, CD, BD, JQ], AbstractAsyncContextManager) :meth:`asyncio.loop.add_signal_handler`. Most notably, the standard event loop on Windows, :class:`asyncio.ProactorEventLoop`, does not implement this method. If this method is not available, stop signals can not be set. + secret_token (:obj:`str`, optional): Secret token to ensure webhook requests originate + from Telegram. See :paramref:`telegram.Bot.set_webhook.secret_token` for more + details. + + When added, the web server started by this call will expect the token to be set in + the ``X-Telegram-Bot-Api-Secret-Token`` header of an incoming request and will + raise a :class:`http.HTTPStatus.FORBIDDEN ` error if either the + header isn't set or it is set to a wrong token. + + .. versionadded:: 20.0 """ if not self.updater: raise RuntimeError( @@ -743,6 +754,7 @@ class Application(Generic[BT, CCT, UD, CD, BD, JQ], AbstractAsyncContextManager) allowed_updates=allowed_updates, ip_address=ip_address, max_connections=max_connections, + secret_token=secret_token, ), close_loop=close_loop, stop_signals=stop_signals, diff --git a/telegram/ext/_updater.py b/telegram/ext/_updater.py index de50dfa7b..a38d877f7 100644 --- a/telegram/ext/_updater.py +++ b/telegram/ext/_updater.py @@ -369,6 +369,7 @@ class Updater(AbstractAsyncContextManager): drop_pending_updates: bool = None, ip_address: str = None, max_connections: int = 40, + secret_token: str = None, ) -> asyncio.Queue: """ Starts a small http server to listen for updates via webhook. If :paramref:`cert` @@ -395,6 +396,7 @@ class Updater(AbstractAsyncContextManager): key (:class:`pathlib.Path` | :obj:`str`, optional): Path to the SSL key file. drop_pending_updates (:obj:`bool`, optional): Whether to clean any pending updates on Telegram servers before actually starting to poll. Default is :obj:`False`. + .. versionadded :: 13.4 bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of the :class:`telegram.ext.Updater` will retry on failures on the Telegram server. @@ -407,12 +409,23 @@ class Updater(AbstractAsyncContextManager): :paramref:`port`, :paramref:`url_path`, :paramref:`cert`, and :paramref:`key`. ip_address (:obj:`str`, optional): Passed to :meth:`telegram.Bot.set_webhook`. Defaults to :obj:`None`. + .. versionadded :: 13.4 allowed_updates (List[:obj:`str`], optional): Passed to :meth:`telegram.Bot.set_webhook`. Defaults to :obj:`None`. max_connections (:obj:`int`, optional): Passed to :meth:`telegram.Bot.set_webhook`. Defaults to ``40``. + .. versionadded:: 13.6 + secret_token (:obj:`str`, optional): Passed to :meth:`telegram.Bot.set_webhook`. + Defaults to :obj:`None`. + + When added, the web server started by this call will expect the token to be set in + the ``X-Telegram-Bot-Api-Secret-Token`` header of an incoming request and will + raise a :class:`http.HTTPStatus.FORBIDDEN ` error if either the + header isn't set or it is set to a wrong token. + + .. versionadded:: 20.0 Returns: :class:`queue.Queue`: The update queue that can be filled from the main thread. @@ -444,6 +457,7 @@ class Updater(AbstractAsyncContextManager): ready=webhook_ready, ip_address=ip_address, max_connections=max_connections, + secret_token=secret_token, ) self._logger.debug("Waiting for webhook server to start") @@ -470,6 +484,7 @@ class Updater(AbstractAsyncContextManager): ready: asyncio.Event = None, ip_address: str = None, max_connections: int = 40, + secret_token: str = None, ) -> None: self._logger.debug("Updater thread started (webhook)") @@ -477,7 +492,7 @@ class Updater(AbstractAsyncContextManager): url_path = f"/{url_path}" # Create Tornado app instance - app = WebhookAppClass(url_path, self.bot, self.update_queue) + app = WebhookAppClass(url_path, self.bot, self.update_queue, secret_token) # Form SSL Context # An SSLError is raised if the private key does not match with the certificate @@ -517,6 +532,7 @@ class Updater(AbstractAsyncContextManager): allowed_updates=allowed_updates, ip_address=ip_address, max_connections=max_connections, + secret_token=secret_token, ) await self._httpd.serve_forever(ready=ready) @@ -591,6 +607,7 @@ class Updater(AbstractAsyncContextManager): bootstrap_interval: float = 1, ip_address: str = None, max_connections: int = 40, + secret_token: str = None, ) -> None: """Prepares the setup for fetching updates: delete or set the webhook and drop pending updates if appropriate. If there are unsuccessful attempts, this will retry as specified by @@ -616,6 +633,7 @@ class Updater(AbstractAsyncContextManager): ip_address=ip_address, drop_pending_updates=drop_pending_updates, max_connections=max_connections, + secret_token=secret_token, ) return False diff --git a/telegram/ext/_utils/webhookhandler.py b/telegram/ext/_utils/webhookhandler.py index 082bd5f45..2f3dba051 100644 --- a/telegram/ext/_utils/webhookhandler.py +++ b/telegram/ext/_utils/webhookhandler.py @@ -83,8 +83,14 @@ class WebhookServer: class WebhookAppClass(tornado.web.Application): """Application used in the Webserver""" - def __init__(self, webhook_path: str, bot: "Bot", update_queue: asyncio.Queue): - self.shared_objects = {"bot": bot, "update_queue": update_queue} + def __init__( + self, webhook_path: str, bot: "Bot", update_queue: asyncio.Queue, secret_token: str = None + ): + self.shared_objects = { + "bot": bot, + "update_queue": update_queue, + "secret_token": secret_token, + } handlers = [(rf"{webhook_path}/?", TelegramHandler, self.shared_objects)] # noqa tornado.web.Application.__init__(self, handlers) # type: ignore @@ -96,16 +102,21 @@ class WebhookAppClass(tornado.web.Application): class TelegramHandler(tornado.web.RequestHandler): """BaseHandler that processes incoming requests from Telegram""" - __slots__ = ("bot", "update_queue", "_logger") + __slots__ = ("bot", "update_queue", "_logger", "secret_token") SUPPORTED_METHODS = ("POST",) # type: ignore[assignment] - def initialize(self, bot: "Bot", update_queue: asyncio.Queue) -> None: + def initialize(self, bot: "Bot", update_queue: asyncio.Queue, secret_token: str) -> None: """Initialize for each request - that's the interface provided by tornado""" # pylint: disable=attribute-defined-outside-init self.bot = bot self.update_queue = update_queue self._logger = logging.getLogger(__name__) + self.secret_token = secret_token + if secret_token: + self._logger.debug( + "The webhook server has a secret token, " "expecting it in incoming requests now" + ) def set_default_headers(self) -> None: """Sets default headers""" @@ -144,6 +155,19 @@ class TelegramHandler(tornado.web.RequestHandler): ct_header = self.request.headers.get("Content-Type", None) if ct_header != "application/json": raise tornado.web.HTTPError(HTTPStatus.FORBIDDEN) + # verifying that the secret token is the one the user set when the user set one + if self.secret_token is not None: + token = self.request.headers.get("X-Telegram-Bot-Api-Secret-Token") + if not token: + self._logger.debug("Request did not include the secret token") + raise tornado.web.HTTPError( + HTTPStatus.FORBIDDEN, reason="Request did not include the secret token" + ) + if token != self.secret_token: + self._logger.debug("Request had the wrong secret token: %s", token) + raise tornado.web.HTTPError( + HTTPStatus.FORBIDDEN, reason="Request had the wrong secret token" + ) def log_exception( self, diff --git a/telegram/ext/filters.py b/telegram/ext/filters.py index 814e2a34e..3c730fc90 100644 --- a/telegram/ext/filters.py +++ b/telegram/ext/filters.py @@ -76,6 +76,8 @@ __all__ = ( "TEXT", "Text", "USER", + "USER_ATTACHMENT", + "PREMIUM_USER", "UpdateFilter", "UpdateType", "User", @@ -1949,6 +1951,21 @@ class Sticker: .. versionadded:: 20.0 """ + class _Premium(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.sticker) and bool( + message.sticker.premium_animation # type: ignore + ) + + PREMIUM = _Premium(name="filters.Sticker.PREMIUM") + """Messages that contain :attr:`telegram.Message.sticker` and have a + :attr:`premium animation `. + + .. versionadded:: 20.0 + """ + class _SuccessfulPayment(MessageFilter): __slots__ = () @@ -2185,6 +2202,41 @@ USER = _User(name="filters.USER") """This filter filters *any* message that has a :attr:`telegram.Message.from_user`.""" +class _UserAttachment(UpdateFilter): + __slots__ = () + + def filter(self, update: Update) -> bool: + return bool(update.effective_user) and bool( + update.effective_user.added_to_attachment_menu # type: ignore + ) + + +USER_ATTACHMENT = _UserAttachment(name="filters.USER_ATTACHMENT") +"""This filter filters *any* message that have a user who added the bot to their +:attr:`attachment menu ` as +:attr:`telegram.Update.effective_user`. + +.. versionadded:: 20.0 +""" + + +class _UserPremium(UpdateFilter): + __slots__ = () + + def filter(self, update: Update) -> bool: + return bool(update.effective_user) and bool( + update.effective_user.is_premium # type: ignore + ) + + +PREMIUM_USER = _UserPremium(name="filters.PREMIUM_USER") +"""This filter filters *any* message from a +:attr:`Telegram Premium user ` as :attr:`telegram.Update.effective_user`. + +.. versionadded:: 20.0 +""" + + class _Venue(MessageFilter): __slots__ = () diff --git a/tests/conftest.py b/tests/conftest.py index dba7001fb..b2efc36be 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -830,10 +830,13 @@ async def send_webhook_message( content_len: int = -1, content_type: str = "application/json", get_method: str = None, + secret_token: str = None, ) -> Response: headers = { "content-type": content_type, } + if secret_token: + headers["X-Telegram-Bot-Api-Secret-Token"] = secret_token if not payload_str: content_len = None diff --git a/tests/test_bot.py b/tests/test_bot.py index 5cfdd53a7..142ef68b5 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -1634,6 +1634,35 @@ class TestBot: assert await bot.set_webhook("", drop_pending_updates=drop_pending_updates) assert await bot.delete_webhook(drop_pending_updates=drop_pending_updates) + async def test_set_webhook_params(self, bot, monkeypatch): + # actually making calls to TG is done in + # test_set_webhook_get_webhook_info_and_delete_webhook. Sadly secret_token can't be tested + # there so we have this function \o/ + async def make_assertion(*args, **_): + kwargs = args[1] + return ( + kwargs["url"] == "example.com" + and kwargs["certificate"].input_file_content + == data_file("sslcert.pem").read_bytes() + and kwargs["max_connections"] == 7 + and kwargs["allowed_updates"] == ["messages"] + and kwargs["ip_address"] == "127.0.0.1" + and kwargs["drop_pending_updates"] + and kwargs["secret_token"] == "SoSecretToken" + ) + + monkeypatch.setattr(bot, "_post", make_assertion) + + assert await bot.set_webhook( + "example.com", + data_file("sslcert.pem").read_bytes(), + 7, + ["messages"], + "127.0.0.1", + True, + "SoSecretToken", + ) + @flaky(3, 1) async def test_leave_chat(self, bot): with pytest.raises(BadRequest, match="Chat not found"): @@ -1833,7 +1862,7 @@ class TestBot: # We assume that the other game score tests ran within 20 sec assert high_scores[0].score == BASE_GAME_SCORE - 10 - # send_invoice is tested in test_invoice + # send_invoice and create_invoice_link is tested in test_invoice # TODO: Needs improvement. Need incoming shipping queries to test async def test_answer_shipping_query_ok(self, monkeypatch, bot): diff --git a/tests/test_chat.py b/tests/test_chat.py index fd32a5bba..979ce01b2 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -42,6 +42,8 @@ def chat(bot): location=TestChat.location, has_private_forwards=True, has_protected_content=True, + join_to_send_messages=True, + join_by_request=True, ) @@ -64,6 +66,8 @@ class TestChat: location = ChatLocation(Location(123, 456), "Barbie World") has_protected_content = True has_private_forwards = True + join_to_send_messages = True + join_by_request = True def test_slot_behaviour(self, chat, mro_slots): for attr in chat.__slots__: @@ -86,6 +90,8 @@ class TestChat: "has_private_forwards": self.has_private_forwards, "linked_chat_id": self.linked_chat_id, "location": self.location.to_dict(), + "join_to_send_messages": self.join_to_send_messages, + "join_by_request": self.join_by_request, } chat = Chat.de_json(json_dict, bot) @@ -104,6 +110,8 @@ class TestChat: assert chat.linked_chat_id == self.linked_chat_id assert chat.location.location == self.location.location assert chat.location.address == self.location.address + assert chat.join_to_send_messages == self.join_to_send_messages + assert chat.join_by_request == self.join_by_request def test_to_dict(self, chat): chat_dict = chat.to_dict() @@ -121,6 +129,8 @@ class TestChat: assert chat_dict["has_protected_content"] == chat.has_protected_content assert chat_dict["linked_chat_id"] == chat.linked_chat_id assert chat_dict["location"] == chat.location.to_dict() + assert chat_dict["join_to_send_messages"] == chat.join_to_send_messages + assert chat_dict["join_by_request"] == chat.join_by_request def test_enum_init(self): chat = Chat(id=1, type="foo") diff --git a/tests/test_filters.py b/tests/test_filters.py index 75baf368a..23e285e28 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -27,6 +27,7 @@ from telegram import ( Chat, Dice, Document, + File, Message, MessageEntity, Sticker, @@ -830,15 +831,26 @@ class TestFilters: update.message.sticker = Sticker("1", "uniq", 1, 2, False, False) assert filters.Sticker.ALL.check_update(update) assert filters.Sticker.STATIC.check_update(update) + assert not filters.Sticker.VIDEO.check_update(update) + assert not filters.Sticker.PREMIUM.check_update(update) update.message.sticker.is_animated = True assert filters.Sticker.ANIMATED.check_update(update) assert not filters.Sticker.VIDEO.check_update(update) assert not filters.Sticker.STATIC.check_update(update) + assert not filters.Sticker.PREMIUM.check_update(update) update.message.sticker.is_animated = False update.message.sticker.is_video = True assert not filters.Sticker.ANIMATED.check_update(update) assert not filters.Sticker.STATIC.check_update(update) assert filters.Sticker.VIDEO.check_update(update) + assert not filters.Sticker.PREMIUM.check_update(update) + update.message.sticker.premium_animation = File("string", "uniqueString") + assert not filters.Sticker.ANIMATED.check_update(update) + # premium stickers can be animated, video, or probably also static, + # it doesn't really matter for the test + assert not filters.Sticker.STATIC.check_update(update) + assert filters.Sticker.VIDEO.check_update(update) + assert filters.Sticker.PREMIUM.check_update(update) def test_filters_video(self, update): assert not filters.VIDEO.check_update(update) @@ -1168,6 +1180,19 @@ class TestFilters: with pytest.raises(RuntimeError, match="Cannot set name"): f.name = "foo" + def test_filters_user_attributes(self, update): + assert not filters.USER_ATTACHMENT.check_update(update) + assert not filters.PREMIUM_USER.check_update(update) + update.message.from_user.added_to_attachment_menu = True + assert filters.USER_ATTACHMENT.check_update(update) + assert not filters.PREMIUM_USER.check_update(update) + update.message.from_user.is_premium = True + assert filters.USER_ATTACHMENT.check_update(update) + assert filters.PREMIUM_USER.check_update(update) + update.message.from_user.added_to_attachment_menu = False + assert not filters.USER_ATTACHMENT.check_update(update) + assert filters.PREMIUM_USER.check_update(update) + def test_filters_chat_init(self): with pytest.raises(RuntimeError, match="in conjunction with"): filters.Chat(chat_id=1, username="chat") diff --git a/tests/test_invoice.py b/tests/test_invoice.py index 1ad1f9a67..df71d687b 100644 --- a/tests/test_invoice.py +++ b/tests/test_invoice.py @@ -98,8 +98,18 @@ class TestInvoice: assert message.invoice.title == self.title assert message.invoice.total_amount == self.total_amount - @flaky(3, 1) - async def test_send_all_args(self, bot, chat_id, provider_token, monkeypatch): + link = await bot.create_invoice_link( + title=self.title, + description=self.description, + payload=self.payload, + provider_token=provider_token, + currency=self.currency, + prices=self.prices, + ) + assert isinstance(link, str) + assert link != "" + + async def test_send_all_args_send_invoice(self, bot, chat_id, provider_token, monkeypatch): message = await bot.send_invoice( chat_id, self.title, @@ -193,6 +203,58 @@ class TestInvoice: protect_content=True, ) + async def test_send_all_args_create_invoice_link( + self, bot, chat_id, provider_token, monkeypatch + ): + async def make_assertion(*args, **_): + kwargs = args[1] + return ( + kwargs["title"] == "title" + and kwargs["description"] == "description" + and kwargs["payload"] == "payload" + and kwargs["provider_token"] == "provider_token" + and kwargs["currency"] == "currency" + and kwargs["prices"] == self.prices + and kwargs["max_tip_amount"] == "max_tip_amount" + and kwargs["suggested_tip_amounts"] == "suggested_tip_amounts" + and kwargs["provider_data"] == "provider_data" + and kwargs["photo_url"] == "photo_url" + and kwargs["photo_size"] == "photo_size" + and kwargs["photo_width"] == "photo_width" + and kwargs["photo_height"] == "photo_height" + and kwargs["need_name"] == "need_name" + and kwargs["need_phone_number"] == "need_phone_number" + and kwargs["need_email"] == "need_email" + and kwargs["need_shipping_address"] == "need_shipping_address" + and kwargs["send_phone_number_to_provider"] == "send_phone_number_to_provider" + and kwargs["send_email_to_provider"] == "send_email_to_provider" + and kwargs["is_flexible"] == "is_flexible" + ) + + monkeypatch.setattr(bot, "_post", make_assertion) + assert await bot.create_invoice_link( + title="title", + description="description", + payload="payload", + provider_token="provider_token", + currency="currency", + prices=self.prices, + max_tip_amount="max_tip_amount", + suggested_tip_amounts="suggested_tip_amounts", + provider_data="provider_data", + photo_url="photo_url", + photo_size="photo_size", + photo_width="photo_width", + photo_height="photo_height", + need_name="need_name", + need_phone_number="need_phone_number", + need_email="need_email", + need_shipping_address="need_shipping_address", + send_phone_number_to_provider="send_phone_number_to_provider", + send_email_to_provider="send_email_to_provider", + is_flexible="is_flexible", + ) + async def test_send_object_as_provider_data(self, monkeypatch, bot, chat_id, provider_token): async def make_assertion(url, request_data: RequestData, *args, **kwargs): return request_data.json_parameters["provider_data"] == '{"test_data": 123456789}' diff --git a/tests/test_sticker.py b/tests/test_sticker.py index 9f9996c01..f44f1556e 100644 --- a/tests/test_sticker.py +++ b/tests/test_sticker.py @@ -23,7 +23,7 @@ from pathlib import Path import pytest from flaky import flaky -from telegram import Audio, Bot, MaskPosition, PhotoSize, Sticker, StickerSet +from telegram import Audio, Bot, File, MaskPosition, PhotoSize, Sticker, StickerSet from telegram.error import BadRequest, TelegramError from telegram.request import RequestData from tests.conftest import ( @@ -91,6 +91,8 @@ class TestSticker: sticker_file_id = "5a3128a4d2a04750b5b58397f3b5e812" sticker_file_unique_id = "adc3145fd2e84d95b64d68eaa22aa33e" + premium_animation = File("this_is_an_id", "this_is_an_unique_id") + def test_slot_behaviour(self, sticker, mro_slots, recwarn): for attr in sticker.__slots__: assert getattr(sticker, attr, "err") != "err", f"got extra slot '{attr}'" @@ -118,6 +120,8 @@ class TestSticker: assert sticker.thumb.width == self.thumb_width assert sticker.thumb.height == self.thumb_height assert sticker.thumb.file_size == self.thumb_file_size + # we need to be a premium TG user to send a premium sticker, so the below is not tested + # assert sticker.premium_animation == self.premium_animation @flaky(3, 1) async def test_send_all_args(self, bot, chat_id, sticker_file, sticker): @@ -135,6 +139,8 @@ class TestSticker: assert message.sticker.is_animated == sticker.is_animated assert message.sticker.is_video == sticker.is_video assert message.sticker.file_size == sticker.file_size + # we need to be a premium TG user to send a premium sticker, so the below is not tested + # assert message.sticker.premium_animation == sticker.premium_animation assert isinstance(message.sticker.thumb, PhotoSize) assert isinstance(message.sticker.thumb.file_id, str) @@ -212,6 +218,7 @@ class TestSticker: "thumb": sticker.thumb.to_dict(), "emoji": self.emoji, "file_size": self.file_size, + "premium_animation": self.premium_animation.to_dict(), } json_sticker = Sticker.de_json(json_dict, bot) @@ -224,6 +231,7 @@ class TestSticker: assert json_sticker.emoji == self.emoji assert json_sticker.file_size == self.file_size assert json_sticker.thumb == sticker.thumb + assert json_sticker.premium_animation == self.premium_animation async def test_send_with_sticker(self, monkeypatch, bot, chat_id, sticker): async def make_assertion(url, request_data: RequestData, *args, **kwargs): @@ -317,6 +325,24 @@ class TestSticker: with pytest.raises(TypeError): await bot.send_sticker(chat_id) + @flaky(3, 1) + async def test_premium_animation(self, bot): + # testing animation sucks a bit since we can't create a premium sticker. What we can do is + # get a sticker set which includes a premium sticker and check that specific one. + premium_sticker_set = await bot.get_sticker_set("Flame") + # the first one to appear here is a sticker with unique file id of AQADOBwAAifPOElr + # this could change in the future ofc. + premium_sticker = premium_sticker_set.stickers[20] + assert premium_sticker.premium_animation.file_unique_id == "AQADOBwAAifPOElr" + assert isinstance(premium_sticker.premium_animation.file_id, str) + assert premium_sticker.premium_animation.file_id != "" + premium_sticker_dict = { + "file_unique_id": "AQADOBwAAifPOElr", + "file_id": premium_sticker.premium_animation.file_id, + "file_size": premium_sticker.premium_animation.file_size, + } + assert premium_sticker.premium_animation.to_dict() == premium_sticker_dict + def test_equality(self, sticker): a = Sticker( sticker.file_id, diff --git a/tests/test_updater.py b/tests/test_updater.py index 330348386..069331fb2 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -504,7 +504,10 @@ class TestUpdater: @pytest.mark.parametrize("ext_bot", [True, False]) @pytest.mark.parametrize("drop_pending_updates", (True, False)) - async def test_webhook_basic(self, monkeypatch, updater, drop_pending_updates, ext_bot): + @pytest.mark.parametrize("secret_token", ["SecretToken", None]) + async def test_webhook_basic( + self, monkeypatch, updater, drop_pending_updates, ext_bot, secret_token + ): # Testing with both ExtBot and Bot to make sure any logic in WebhookHandler # that depends on this distinction works if ext_bot and not isinstance(updater.bot, ExtBot): @@ -533,13 +536,16 @@ class TestUpdater: ip_address=ip, port=port, url_path="TOKEN", + secret_token=secret_token, ) assert return_value is updater.update_queue assert updater.running # Now, we send an update to the server update = make_message_update("Webhook") - await send_webhook_message(ip, port, update.to_json(), "TOKEN") + await send_webhook_message( + ip, port, update.to_json(), "TOKEN", secret_token=secret_token + ) assert (await updater.update_queue.get()).to_dict() == update.to_dict() # Returns Not Found if path is incorrect @@ -550,6 +556,22 @@ class TestUpdater: response = await send_webhook_message(ip, port, None, "TOKEN", get_method="HEAD") assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED + if secret_token: + # Returns Forbidden if no secret token is set + response_text = "403: {0}403: {0}" + response = await send_webhook_message(ip, port, update.to_json(), "TOKEN") + assert response.status_code == HTTPStatus.FORBIDDEN + assert response.text == response_text.format( + "Request did not include the secret token" + ) + + # Returns Forbidden if the secret token is wrong + response = await send_webhook_message( + ip, port, update.to_json(), "TOKEN", secret_token="NotTheSecretToken" + ) + assert response.status_code == HTTPStatus.FORBIDDEN + assert response.text == response_text.format("Request had the wrong secret token") + await updater.stop() assert not updater.running @@ -600,6 +622,7 @@ class TestUpdater: max_connections=40, allowed_updates=None, ip_address=None, + secret_token=None, **expected_delete_webhook, ) @@ -641,6 +664,7 @@ class TestUpdater: max_connections=47, allowed_updates=["message"], ip_address="123.456.789", + secret_token=None, **expected_delete_webhook, ) diff --git a/tests/test_user.py b/tests/test_user.py index d3ff04aab..f077debd3 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -35,6 +35,8 @@ def json_dict(): "can_join_groups": TestUser.can_join_groups, "can_read_all_group_messages": TestUser.can_read_all_group_messages, "supports_inline_queries": TestUser.supports_inline_queries, + "is_premium": TestUser.is_premium, + "added_to_attachment_menu": TestUser.added_to_attachment_menu, } @@ -51,6 +53,8 @@ def user(bot): can_read_all_group_messages=TestUser.can_read_all_group_messages, supports_inline_queries=TestUser.supports_inline_queries, bot=bot, + is_premium=TestUser.is_premium, + added_to_attachment_menu=TestUser.added_to_attachment_menu, ) @@ -64,6 +68,8 @@ class TestUser: can_join_groups = True can_read_all_group_messages = True supports_inline_queries = False + is_premium = True + added_to_attachment_menu = False def test_slot_behaviour(self, user, mro_slots): for attr in user.__slots__: @@ -82,6 +88,8 @@ class TestUser: assert user.can_join_groups == self.can_join_groups assert user.can_read_all_group_messages == self.can_read_all_group_messages assert user.supports_inline_queries == self.supports_inline_queries + assert user.is_premium == self.is_premium + assert user.added_to_attachment_menu == self.added_to_attachment_menu def test_de_json_without_username(self, json_dict, bot): del json_dict["username"] @@ -97,6 +105,8 @@ class TestUser: assert user.can_join_groups == self.can_join_groups assert user.can_read_all_group_messages == self.can_read_all_group_messages assert user.supports_inline_queries == self.supports_inline_queries + assert user.is_premium == self.is_premium + assert user.added_to_attachment_menu == self.added_to_attachment_menu def test_de_json_without_username_and_last_name(self, json_dict, bot): del json_dict["username"] @@ -113,6 +123,8 @@ class TestUser: assert user.can_join_groups == self.can_join_groups assert user.can_read_all_group_messages == self.can_read_all_group_messages assert user.supports_inline_queries == self.supports_inline_queries + assert user.is_premium == self.is_premium + assert user.added_to_attachment_menu == self.added_to_attachment_menu def test_name(self, user): assert user.name == "@username"