diff --git a/README.rst b/README.rst index 84f80c9bb4f..44cbe11f12e 100644 --- a/README.rst +++ b/README.rst @@ -11,7 +11,7 @@ :target: https://pypi.org/project/python-telegram-bot/ :alt: Supported Python versions -.. image:: https://img.shields.io/badge/Bot%20API-9.6-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-10.0-blue?logo=telegram :target: https://core.telegram.org/bots/api-changelog :alt: Supported Bot API version @@ -77,7 +77,7 @@ After installing_ the library, be sure to check out the section on `working with Telegram API support ~~~~~~~~~~~~~~~~~~~~ -All types and methods of the Telegram Bot API **9.6** are natively supported by this library. +All types and methods of the Telegram Bot API **10.0** are natively supported by this library. In addition, Bot API functionality not yet natively included can still be used as described `in our wiki `_. Notable Features diff --git a/changes/unreleased/5196.7keq7yJhXbMb9RyShLHz4D.toml b/changes/unreleased/5196.7keq7yJhXbMb9RyShLHz4D.toml index 9a1ba1501e1..4d79925fa99 100644 --- a/changes/unreleased/5196.7keq7yJhXbMb9RyShLHz4D.toml +++ b/changes/unreleased/5196.7keq7yJhXbMb9RyShLHz4D.toml @@ -1,4 +1,21 @@ -features = "Full Support for Bot API 9.6" +features = """ +Full Support for Bot API 9.6 + +.. warning:: + + - Bot API 9.6 replaces the field ``correct_option_id`` of ``Poll`` with the new field ``correct_option_ids``. The field ``correct_option_id`` is still present in PTB for backward compatibility, but it will be removed in future releases. + + - Bot API 9.6 replaces the argument ``correct_option_id`` of ``Bot.send_poll`` with the new argument ``correct_option_ids``. The argument ``correct_option_id`` is still present in PTB for backward compatibility, but it will be removed in future releases. + + - Bot API 9.6 introduces a now required argument ``persistent_id`` to ``PollOption``. For backward compatibility, the argument is currently still marked as optional in the signature and its presence is enforced through a runtime check. In future versions, this argument will be made required in the signature as well. + + - Bot API 9.6 introduces a now required argument ``option_persistent_ids`` to ``PollAnswer``. For backward compatibility, the argument is currently still marked as optional in the signature and its presence is enforced through a runtime check. In future versions, this argument will be made required in the signature as well. + + - Bot API 9.6 introduces a now required argument ``allows_revoting`` to ``Poll``. For backward compatibility, the argument is currently still marked as optional in the signature and its presence is enforced through a runtime check. In future versions, this argument will be made required in the signature as well. + + + Please make sure to update your code accordingly to avoid potential issues in the future. We recommend using keyword arguments to ensure compatibility with future updates. +""" pull_requests = [ { uid = "5196", author_uid = "harshil21" }, diff --git a/changes/unreleased/5229.87PBN4GFkuAaDhhgFwCYkY.toml b/changes/unreleased/5229.87PBN4GFkuAaDhhgFwCYkY.toml new file mode 100644 index 00000000000..d0435053de4 --- /dev/null +++ b/changes/unreleased/5229.87PBN4GFkuAaDhhgFwCYkY.toml @@ -0,0 +1,33 @@ +features = """ +Full Support for Bot API 10.0 + +.. warning:: + + - Bot API 10.0 introduces a now required argument ``members_only`` to ``Poll``. For backward compatibility, the argument is currently still marked as optional in the signature and its presence is enforced through a runtime check. In future versions, this argument will be made required in the signature as well. + + Please make sure to update your code accordingly to avoid potential issues in the future. We recommend using keyword arguments to ensure compatibility with future updates. +""" + +deprecations = """ +* Deprecated passing the ``filename`` parameter positionally to the classes: + + * ``InputMediaAnimation`` + * ``InputMediaAudio`` + * ``InputMediaPhoto`` + * ``InputMediaDocument`` + * ``InputMediaVideo`` + + Please pass ``filename`` as a keyword argument instead, as this parameter will become keyword-only in the future. + +* Deprecated ``InputPollOption.de_json``. The class ``InputPollOption`` is input only and its ``de_json`` method will be removed in future versions. The Bot API 10.0 ``media`` field of ``InputPollOption`` will not be included for deserialization. + +""" + +pull_requests = [ + { uid = "5229", author_uid = "aelkheir", closes_threads = ["5228"] }, + { uid = "5230", author_uid = "harshil21" }, + { uid = "5235", author_uid = "harshil21" }, + { uid = "5238", author_uid = "harshil21" }, + { uid = "5232", author_uid = "aelkheir" }, + { uid = "5232", author_uid = ["Poolitzer", "Phil9l", "harshil21", "aelkheir"] }, +] diff --git a/docs/auxil/sphinx_hooks.py b/docs/auxil/sphinx_hooks.py index 7183a0dbaf7..796c367e2b0 100644 --- a/docs/auxil/sphinx_hooks.py +++ b/docs/auxil/sphinx_hooks.py @@ -52,6 +52,7 @@ "_BaseMedium": "TelegramObject", "_CredentialsBase": "TelegramObject", "_ChatBase": "TelegramObject", + "_BaseInputMedia": "TelegramObject", } diff --git a/docs/source/inclusions/bot_methods.rst b/docs/source/inclusions/bot_methods.rst index 72f58dea7bd..0a0a8e0f9a6 100644 --- a/docs/source/inclusions/bot_methods.rst +++ b/docs/source/inclusions/bot_methods.rst @@ -41,6 +41,8 @@ - Used for sending paid media to channels * - :meth:`~telegram.Bot.send_photo` - Used for sending photos + * - :meth:`~telegram.Bot.send_live_photo` + - Used for sending live photos * - :meth:`~telegram.Bot.send_poll` - Used for sending polls * - :meth:`~telegram.Bot.send_sticker` @@ -80,6 +82,8 @@ - Used for answering the callback query * - :meth:`~telegram.Bot.answer_inline_query` - Used for answering the inline query + * - :meth:`~telegram.Bot.answer_guest_query` + - Used for replying to a received guest message * - :meth:`~telegram.Bot.answer_pre_checkout_query` - Used for answering a pre checkout query * - :meth:`~telegram.Bot.answer_shipping_query` @@ -104,6 +108,10 @@ - Used for stopping the running poll * - :meth:`~telegram.Bot.set_message_reaction` - Used for setting reactions on messages + * - :meth:`~telegram.Bot.delete_message_reaction` + - Used for deleting reactions on messages + * - :meth:`~telegram.Bot.delete_all_message_reactions` + - Used for deleting all reactions by a chat or user .. raw:: html @@ -167,6 +175,8 @@ - Used for unpinning a message * - :meth:`~telegram.Bot.unpin_all_chat_messages` - Used for unpinning all pinned chat messages + * - :meth:`~telegram.Bot.get_user_personal_chat_messages` + - Used for obtaining the personal chat messages of a user * - :meth:`~telegram.Bot.get_user_profile_audios` - Used for obtaining user's profile audios * - :meth:`~telegram.Bot.get_user_profile_photos` @@ -237,6 +247,10 @@ - Used for obtaining the menu button of a private chat or the default menu button * - :meth:`~telegram.Bot.set_chat_menu_button` - Used for setting the menu button of a private chat or the default menu button + * - :meth:`~telegram.Bot.set_managed_bot_access_settings` + - Used for changing the access settings of a managed bot + * - :meth:`~telegram.Bot.get_managed_bot_access_settings` + - Used for obtaining the access settings of a managed bot * - :meth:`~telegram.Bot.set_my_description` - Used for setting the description of the bot * - :meth:`~telegram.Bot.get_my_description` diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index 25d9c269838..86f76d0f6ca 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -8,6 +8,7 @@ Available Types telegram.animation telegram.audio telegram.birthdate + telegram.botaccesssettings telegram.botcommand telegram.botcommandscope telegram.botcommandscopeallchatadministrators @@ -101,15 +102,22 @@ Available Types telegram.inputmediaanimation telegram.inputmediaaudio telegram.inputmediadocument + telegram.inputmedialivephoto + telegram.inputmedialocation telegram.inputmediaphoto + telegram.inputmediasticker + telegram.inputmediavenue telegram.inputmediavideo telegram.inputpaidmedia + telegram.inputpaidmedialivephoto telegram.inputpaidmediaphoto telegram.inputpaidmediavideo + telegram.inputpollmedia telegram.inputprofilephoto telegram.inputprofilephotoanimated telegram.inputprofilephotostatic telegram.inputpolloption + telegram.inputpolloptionmedia telegram.inputstorycontent telegram.inputstorycontentphoto telegram.inputstorycontentvideo @@ -119,6 +127,7 @@ Available Types telegram.keyboardbuttonrequestmanagedbot telegram.keyboardbuttonrequestusers telegram.linkpreviewoptions + telegram.livephoto telegram.location telegram.locationaddress telegram.loginurl @@ -146,6 +155,7 @@ Available Types telegram.ownedgiftunique telegram.paidmedia telegram.paidmediainfo + telegram.paidmedialivephoto telegram.paidmediaphoto telegram.paidmediapreview telegram.paidmediapurchased @@ -154,6 +164,7 @@ Available Types telegram.photosize telegram.poll telegram.pollanswer + telegram.pollmedia telegram.polloptionadded telegram.polloptiondeleted telegram.preparedkeyboardbutton @@ -166,6 +177,7 @@ Available Types telegram.replykeyboardmarkup telegram.replykeyboardremove telegram.replyparameters + telegram.sentguestmessage telegram.sentwebappmessage telegram.shareduser telegram.story diff --git a/docs/source/telegram.botaccesssettings.rst b/docs/source/telegram.botaccesssettings.rst new file mode 100644 index 00000000000..788689b9266 --- /dev/null +++ b/docs/source/telegram.botaccesssettings.rst @@ -0,0 +1,6 @@ +BotAccessSettings +================= + +.. autoclass:: telegram.BotAccessSettings + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.inputmedialivephoto.rst b/docs/source/telegram.inputmedialivephoto.rst new file mode 100644 index 00000000000..975614554a1 --- /dev/null +++ b/docs/source/telegram.inputmedialivephoto.rst @@ -0,0 +1,6 @@ +InputMediaLivePhoto +=================== + +.. autoclass:: telegram.InputMediaLivePhoto + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.inputmedialocation.rst b/docs/source/telegram.inputmedialocation.rst new file mode 100644 index 00000000000..aa20d631ea4 --- /dev/null +++ b/docs/source/telegram.inputmedialocation.rst @@ -0,0 +1,6 @@ +InputMediaLocation +================== + +.. autoclass:: telegram.InputMediaLocation + :members: + :show-inheritance: diff --git a/docs/source/telegram.inputmediasticker.rst b/docs/source/telegram.inputmediasticker.rst new file mode 100644 index 00000000000..7f2b6d7778e --- /dev/null +++ b/docs/source/telegram.inputmediasticker.rst @@ -0,0 +1,6 @@ +InputMediaSticker +================= + +.. autoclass:: telegram.InputMediaSticker + :members: + :show-inheritance: diff --git a/docs/source/telegram.inputmediavenue.rst b/docs/source/telegram.inputmediavenue.rst new file mode 100644 index 00000000000..e5e221e2f73 --- /dev/null +++ b/docs/source/telegram.inputmediavenue.rst @@ -0,0 +1,6 @@ +InputMediaVenue +=============== + +.. autoclass:: telegram.InputMediaVenue + :members: + :show-inheritance: diff --git a/docs/source/telegram.inputpaidmedialivephoto.rst b/docs/source/telegram.inputpaidmedialivephoto.rst new file mode 100644 index 00000000000..62bf1a2dac6 --- /dev/null +++ b/docs/source/telegram.inputpaidmedialivephoto.rst @@ -0,0 +1,6 @@ +InputPaidMediaLivePhoto +====================== + +.. autoclass:: telegram.InputPaidMediaLivePhoto + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.inputpollmedia.rst b/docs/source/telegram.inputpollmedia.rst new file mode 100644 index 00000000000..6178a00d4a4 --- /dev/null +++ b/docs/source/telegram.inputpollmedia.rst @@ -0,0 +1,6 @@ +InputPollMedia +============== + +.. versionadded:: NEXT.VERSION + +.. autoclass:: telegram.InputPollMedia diff --git a/docs/source/telegram.inputpolloptionmedia.rst b/docs/source/telegram.inputpolloptionmedia.rst new file mode 100644 index 00000000000..2af6693ad19 --- /dev/null +++ b/docs/source/telegram.inputpolloptionmedia.rst @@ -0,0 +1,6 @@ +InputPollOptionMedia +==================== + +.. versionadded:: NEXT.VERSION + +.. autoclass:: telegram.InputPollOptionMedia diff --git a/docs/source/telegram.livephoto.rst b/docs/source/telegram.livephoto.rst new file mode 100644 index 00000000000..969e4b4db4c --- /dev/null +++ b/docs/source/telegram.livephoto.rst @@ -0,0 +1,6 @@ +LivePhoto +========= + +.. autoclass:: telegram.LivePhoto + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.paidmedialivephoto.rst b/docs/source/telegram.paidmedialivephoto.rst new file mode 100644 index 00000000000..12fe8058ce9 --- /dev/null +++ b/docs/source/telegram.paidmedialivephoto.rst @@ -0,0 +1,6 @@ +PaidMediaLivePhoto +================== + +.. autoclass:: telegram.PaidMediaLivePhoto + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.pollmedia.rst b/docs/source/telegram.pollmedia.rst new file mode 100644 index 00000000000..8e7b38871b4 --- /dev/null +++ b/docs/source/telegram.pollmedia.rst @@ -0,0 +1,6 @@ +PollMedia +========= + +.. autoclass:: telegram.PollMedia + :members: + :show-inheritance: diff --git a/docs/source/telegram.sentguestmessage.rst b/docs/source/telegram.sentguestmessage.rst new file mode 100644 index 00000000000..b63e0735029 --- /dev/null +++ b/docs/source/telegram.sentguestmessage.rst @@ -0,0 +1,6 @@ +SentGuestMessage +================ + +.. autoclass:: telegram.SentGuestMessage + :members: + :show-inheritance: diff --git a/src/telegram/__init__.py b/src/telegram/__init__.py index 362f3253198..f0304c4962b 100644 --- a/src/telegram/__init__.py +++ b/src/telegram/__init__.py @@ -35,6 +35,7 @@ "BackgroundTypeWallpaper", "Birthdate", "Bot", + "BotAccessSettings", "BotCommand", "BotCommandScope", "BotCommandScopeAllChatAdministrators", @@ -157,13 +158,20 @@ "InputMediaAnimation", "InputMediaAudio", "InputMediaDocument", + "InputMediaLivePhoto", + "InputMediaLocation", "InputMediaPhoto", + "InputMediaSticker", + "InputMediaVenue", "InputMediaVideo", "InputMessageContent", "InputPaidMedia", + "InputPaidMediaLivePhoto", "InputPaidMediaPhoto", "InputPaidMediaVideo", + "InputPollMedia", "InputPollOption", + "InputPollOptionMedia", "InputProfilePhoto", "InputProfilePhotoAnimated", "InputProfilePhotoStatic", @@ -181,6 +189,7 @@ "KeyboardButtonRequestUsers", "LabeledPrice", "LinkPreviewOptions", + "LivePhoto", "Location", "LocationAddress", "LoginUrl", @@ -210,6 +219,7 @@ "OwnedGifts", "PaidMedia", "PaidMediaInfo", + "PaidMediaLivePhoto", "PaidMediaPhoto", "PaidMediaPreview", "PaidMediaPurchased", @@ -231,6 +241,7 @@ "PhotoSize", "Poll", "PollAnswer", + "PollMedia", "PollOption", "PollOptionAdded", "PollOptionDeleted", @@ -254,6 +265,7 @@ "RevenueWithdrawalStateSucceeded", "SecureData", "SecureValue", + "SentGuestMessage", "SentWebAppMessage", "SharedUser", "ShippingAddress", @@ -348,6 +360,7 @@ from . import _version, constants, error, helpers, request, warnings from ._birthdate import Birthdate from ._bot import Bot +from ._botaccesssettings import BotAccessSettings from ._botcommand import BotCommand from ._botcommandscope import ( BotCommandScope, @@ -435,11 +448,18 @@ InputMediaAnimation, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, + InputMediaLocation, InputMediaPhoto, + InputMediaSticker, + InputMediaVenue, InputMediaVideo, InputPaidMedia, + InputPaidMediaLivePhoto, InputPaidMediaPhoto, InputPaidMediaVideo, + InputPollMedia, + InputPollOptionMedia, ) from ._files.inputprofilephoto import ( InputProfilePhoto, @@ -447,6 +467,7 @@ InputProfilePhotoStatic, ) from ._files.inputsticker import InputSticker +from ._files.livephoto import LivePhoto from ._files.location import Location from ._files.photosize import PhotoSize from ._files.sticker import MaskPosition, Sticker, StickerSet @@ -529,6 +550,7 @@ from ._paidmedia import ( PaidMedia, PaidMediaInfo, + PaidMediaLivePhoto, PaidMediaPhoto, PaidMediaPreview, PaidMediaPurchased, @@ -579,6 +601,7 @@ InputPollOption, Poll, PollAnswer, + PollMedia, PollOption, PollOptionAdded, PollOptionDeleted, @@ -595,6 +618,7 @@ from ._reply import ExternalReplyInfo, ReplyParameters, TextQuote from ._replykeyboardmarkup import ReplyKeyboardMarkup from ._replykeyboardremove import ReplyKeyboardRemove +from ._sentguestmessage import SentGuestMessage from ._sentwebappmessage import SentWebAppMessage from ._shared import ChatShared, SharedUser, UsersShared from ._story import Story diff --git a/src/telegram/_bot.py b/src/telegram/_bot.py index d9d1f83b069..0517b9220eb 100644 --- a/src/telegram/_bot.py +++ b/src/telegram/_bot.py @@ -47,7 +47,8 @@ serialization = None # type: ignore[assignment] CRYPTO_INSTALLED = False -from telegram._botcommand import BotCommand # pylint: disable=ungrouped-imports +from telegram._botaccesssettings import BotAccessSettings # pylint: disable=ungrouped-imports +from telegram._botcommand import BotCommand from telegram._botcommandscope import BotCommandScope from telegram._botdescription import BotDescription, BotShortDescription from telegram._botname import BotName @@ -65,6 +66,7 @@ from telegram._files.document import Document from telegram._files.file import File from telegram._files.inputmedia import InputMedia, InputPaidMedia +from telegram._files.livephoto import LivePhoto from telegram._files.location import Location from telegram._files.photosize import PhotoSize from telegram._files.sticker import MaskPosition, Sticker, StickerSet @@ -89,6 +91,7 @@ from telegram._preparedkeyboardbutton import PreparedKeyboardButton from telegram._reaction import ReactionType, ReactionTypeCustomEmoji, ReactionTypeEmoji from telegram._reply import ReplyParameters +from telegram._sentguestmessage import SentGuestMessage from telegram._sentwebappmessage import SentWebAppMessage from telegram._story import Story from telegram._telegramobject import TelegramObject @@ -126,8 +129,10 @@ InputFile, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, InputMediaPhoto, InputMediaVideo, + InputPollMedia, InputProfilePhoto, InputSticker, InputStoryContent, @@ -1209,7 +1214,7 @@ async def send_message_draft( self, chat_id: int, draft_id: int, - text: str, + text: str | None = None, message_thread_id: int | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, entities: Sequence["MessageEntity"] | None = None, @@ -1221,7 +1226,9 @@ async def send_message_draft( api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to stream a partial message to a user while the message is being - generated. + generated. Note that the streamed draft is ephemeral and acts as a temporary 30-second + preview - once the output is finalized, you must call :meth:`~Bot.send_message` with + the complete message to persist it in the user's chat. .. versionadded:: 22.6 @@ -1233,19 +1240,21 @@ async def send_message_draft( chat_id (:obj:`int`): Unique identifier for the target private chat. draft_id (:obj:`int`): Unique identifier of the message draft; must be non-zero. Changes of drafts with the same identifier are animated. - text (:obj:`str`): Text of the message to be sent, - :tg-const:`telegram.constants.MessageLimit.MIN_TEXT_LENGTH`- - :tg-const:`telegram.constants.MessageLimit.MAX_TEXT_LENGTH` characters after - entities parsing. + text (:obj:`str`, optional): Text of the message to be sent, + 0-:tg-const:`telegram.constants.MessageLimit.MAX_TEXT_LENGTH` characters after + entities parsing. Pass an empty text to show a "Thinking..." placeholder. + + .. versionchanged:: NEXT.VERSION + Bot API 10.0 now makes this an optional parameter. + + message_thread_id (:obj:`int`, optional): Unique identifier for the target + message thread. parse_mode (:obj:`str`): |parse_mode| entities (Sequence[:class:`telegram.MessageEntity`], optional): Sequence of special entities that appear in message text, which can be specified instead of :paramref:`parse_mode`. |sequenceargs| - message_thread_id (:obj:`int`, optional): Unique identifier for the target - message thread. - Returns: :obj:`bool`: On success, :obj:`True` is returned. @@ -2839,7 +2848,7 @@ async def send_media_group( self, chat_id: int | str, media: Sequence[ - "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo | InputMediaLivePhoto" # noqa: E501 # pylint: disable=line-too-long ], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, @@ -2878,8 +2887,8 @@ async def send_media_group( chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| media (Sequence[:class:`telegram.InputMediaAudio`,\ :class:`telegram.InputMediaDocument`, :class:`telegram.InputMediaPhoto`,\ - :class:`telegram.InputMediaVideo`]): An array - describing messages to be sent, must include + :class:`telegram.InputMediaVideo`, :class:`telegram.InputMediaLivePhoto`]): An + array describing messages to be sent, must include :tg-const:`telegram.constants.MediaGroupLimit.MIN_MEDIA_LENGTH`- :tg-const:`telegram.constants.MediaGroupLimit.MAX_MEDIA_LENGTH` items. @@ -3677,7 +3686,7 @@ async def send_contact( async def send_game( self, - chat_id: int, + chat_id: int | str, game_short_name: str, disable_notification: ODVInput[bool] = DEFAULT_NONE, reply_markup: "InlineKeyboardMarkup | None" = None, @@ -3699,7 +3708,9 @@ async def send_game( """Use this method to send a game. Args: - chat_id (:obj:`int`): Unique identifier for the target chat. + chat_id (:obj:`int`): Unique identifier for the target chat or username of the + target bot in the format ``@username``. Games can't be sent to channel direct + messages chats and channel chats. game_short_name (:obj:`str`): Short name of the game, serves as the unique identifier for the game. Set up your games via `@BotFather `_. disable_notification (:obj:`bool`, optional): |disable_notification| @@ -4658,12 +4669,12 @@ async def edit_message_media( api_kwargs: JSONDict | None = None, ) -> "Message | bool": """ - Use this method to edit animation, audio, document, photo, or video messages, or to add - media to text messages. If a message + Use this method to edit animation, audio, document, live photo, photo, or video messages, + or to add media to text messages. If a message is part of a message album, then it can be edited only to an audio for audio albums, only - to a document for document albums and to a photo or a video otherwise. When an inline - message is edited, a new file can't be uploaded; use a previously uploaded file via its - :attr:`~telegram.File.file_id` or specify a URL. + to a document for document albums and to a photo, live photo, or a video otherwise. + When an inline message is edited, a new file can't be uploaded; use a previously + uploaded file via its :attr:`~telegram.File.file_id` or specify a URL. Note: * |editreplymarkup| @@ -5125,6 +5136,7 @@ async def get_chat( async def get_chat_administrators( self, chat_id: str | int, + return_bots: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -5140,18 +5152,21 @@ async def get_chat_administrators( Args: chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + return_bots (:obj:`bool`, optional): Pass :obj:`True` to additionally receive all bots + that are administrators of the chat. By default, bots other than the current bot + are omitted. + + .. versionadded:: NEXT.VERSION Returns: tuple[:class:`telegram.ChatMember`]: On success, returns a tuple of ``ChatMember`` - objects that contains information about all chat administrators except - other bots. If the chat is a group or a supergroup and no administrators were - appointed, only the creator will be returned. + objects that contains information about all chat administrators. Raises: :class:`telegram.error.TelegramError` """ - data: JSONDict = {"chat_id": chat_id} + data: JSONDict = {"chat_id": chat_id, "return_bots": return_bots} result = await self._post( "getChatAdministrators", data, @@ -5848,6 +5863,51 @@ async def answer_web_app_query( return SentWebAppMessage.de_json(api_result, self) + async def answer_guest_query( + self, + guest_query_id: str, + result: "InlineQueryResult", + *, + 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 = None, + ) -> SentGuestMessage: + """Use this method to reply to a received guest message. + + .. versionadded:: NEXT.VERSION + + Args: + guest_query_id (:obj:`str`): Unique identifier for the query to be answered. + result (:class:`telegram.InlineQueryResult`): An object describing the message to be + sent. + + Returns: + :class:`telegram.SentGuestMessage`: On success, a + :class:`telegram.SentGuestMessage` is returned. + + Raises: + :class:`telegram.error.TelegramError` + + """ + data: JSONDict = { + "guest_query_id": guest_query_id, + "result": self._insert_defaults_for_ilq_results(result), + } + + api_result = await self._post( + "answerGuestQuery", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + return SentGuestMessage.de_json(api_result, self) + async def restrict_chat_member( self, chat_id: str | int, @@ -7624,9 +7684,13 @@ async def send_poll( hide_results_until_closes: bool | None = None, correct_option_ids: CorrectOptionIds | None = None, description: str | None = None, - description_parse_mode: str | None = None, + description_parse_mode: ODVInput[str] | None = None, description_entities: Sequence["MessageEntity"] | None = None, shuffle_options: bool | None = None, + members_only: bool | None = None, + country_codes: Sequence[str] | None = None, + explanation_media: "InputPollMedia | None" = None, + media: "InputPollMedia | None" = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, reply_to_message_id: int | None = None, @@ -7769,6 +7833,27 @@ async def send_poll( shuffle_options (:obj:`bool`, optional): :obj:`True`, if the poll options must be shown in random order + .. versionadded:: NEXT.VERSION + members_only (:obj:`bool`, optional): :obj:`True`, if voting is limited to users who + have been members of the chat where the poll is being sent for more than + :tg-const:`telegram.Poll.MIN_MEMBERSHIP_HOURS` hours; for channel chats only + + .. versionadded:: NEXT.VERSION + country_codes (Sequence[:obj:`str`], optional): A list of + 0-:tg-const:`telegram.constants.PollLimit.MAX_COUNTRY_CODES` two-letter + ``ISO 3166-1 alpha-2`` country codes indicating the countries from which users can + vote in the poll; for channel chats only. Use ``"FT"`` as a country code to allow + users with anonymous numbers to vote. If omitted or empty, then users from any + country can participate in the poll. + + .. versionadded:: NEXT.VERSION + explanation_media (:class:`telegram.InputPollMedia`, optional): Media added to the quiz + explanation + + .. versionadded:: NEXT.VERSION + media (:class:`telegram.InputPollMedia`, optional): Media added to the poll + description. + .. versionadded:: NEXT.VERSION Keyword Args: @@ -7837,6 +7922,10 @@ async def send_poll( "close_date": close_date, "question_parse_mode": question_parse_mode, "question_entities": question_entities, + "members_only": members_only, + "country_codes": country_codes, + "explanation_media": explanation_media, + "media": media, } return await self._send_message( @@ -7912,7 +8001,7 @@ async def stop_poll( async def send_checklist( self, business_connection_id: str, - chat_id: int, + chat_id: int | str, checklist: InputChecklist, disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, @@ -7934,20 +8023,14 @@ async def send_checklist( .. versionadded:: 22.3 Args: - business_connection_id (:obj:`str`): - |business_id_str| - chat_id (:obj:`int`): - Unique identifier for the target chat. - checklist (:class:`telegram.InputChecklist`): - The checklist to send. - disable_notification (:obj:`bool`, optional): - |disable_notification| - protect_content (:obj:`bool`, optional): - |protect_content| - message_effect_id (:obj:`str`, optional): - |message_effect_id| - reply_parameters (:class:`telegram.ReplyParameters`, optional): - |reply_parameters| + business_connection_id (:obj:`str`): |business_id_str| + chat_id (:obj:`int`): Unique identifier for the target chat or username of the target + bot in the format ``@username``. + checklist (:class:`telegram.InputChecklist`): The checklist to send. + disable_notification (:obj:`bool`, optional): |disable_notification| + protect_content (:obj:`bool`, optional): |protect_content| + message_effect_id (:obj:`str`, optional): |message_effect_id| + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): An object for an inline keyboard @@ -7992,7 +8075,7 @@ async def send_checklist( async def edit_message_checklist( self, business_connection_id: str, - chat_id: int, + chat_id: int | str, message_id: int, checklist: InputChecklist, reply_markup: "InlineKeyboardMarkup | None" = None, @@ -8009,14 +8092,11 @@ async def edit_message_checklist( .. versionadded:: 22.3 Args: - business_connection_id (:obj:`str`): - |business_id_str| - chat_id (:obj:`int`): - Unique identifier for the target chat. - message_id (:obj:`int`): - Unique identifier for the target message. - checklist (:class:`telegram.InputChecklist`): - The new checklist. + business_connection_id (:obj:`str`): |business_id_str| + chat_id (:obj:`int`): Unique identifier for the target chat or username of the target + bot in the format ``@username``. + message_id (:obj:`int`): Unique identifier for the target message. + checklist (:class:`telegram.InputChecklist`): The new checklist. reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): An object for the new inline keyboard for the message. @@ -11123,6 +11203,145 @@ async def edit_user_star_subscription( api_kwargs=api_kwargs, ) + async def get_managed_bot_access_settings( + self, + user_id: int, + *, + 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 = None, + ) -> BotAccessSettings: + """ + Use this method to get the access settings of a managed bot. + + .. versionadded:: NEXT.VERSION + + Args: + user_id (:obj:`int`): User identifier of the managed bot whose access settings will be + returned. + + Returns: + :class:`telegram.BotAccessSettings`: The access settings of the managed bot. + + Raises: + :class:`telegram.error.TelegramError` + """ + + data: JSONDict = { + "user_id": user_id, + } + + return BotAccessSettings.de_json( + await self._post( + "getManagedBotAccessSettings", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ), + self, + ) + + async def set_managed_bot_access_settings( + self, + user_id: int, + is_access_restricted: bool, + added_user_ids: Sequence[int] | None = 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 = None, + ) -> bool: + """ + Use this method to change the access settings of a managed bot. + + .. versionadded:: NEXT.VERSION + + Args: + user_id (:obj:`int`): User identifier of the managed bot whose access settings will be + changed. + is_access_restricted (:obj:`bool`): Pass :obj:`True`, if only selected users can access + the bot. The bot's owner can always access it. + added_user_ids (Sequence[:obj:`int`], optional): A list of up to + :tg-const:`telegram.constants.ManagedBotAccessLimit.MAX_ALLOWED_USERS` + identifiers of users who will have access to the bot in addition to its owner. + Ignored if :paramref:`is_access_restricted` is :obj:`False`. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + + data: JSONDict = { + "user_id": user_id, + "is_access_restricted": is_access_restricted, + "added_user_ids": added_user_ids, + } + + return await self._post( + "setManagedBotAccessSettings", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def get_user_personal_chat_messages( + self, + user_id: int, + limit: int, + *, + 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 = None, + ) -> tuple[Message, ...]: + """ + Use this method to get the last messages from the personal chat (i.e., the chat currently + added to their profile) of a given user. + + .. versionadded:: NEXT.VERSION + + Args: + user_id (:obj:`int`): Unique identifier of the target user. + limit (:obj:`int`): The maximum number of messages to return; + :tg-const:`telegram.constants.PersonalChatMessagesLimit.MIN_LIMIT`- + :tg-const:`telegram.constants.PersonalChatMessagesLimit.MAX_LIMIT`. + + Returns: + tuple[:class:`telegram.Message`, ...]: On success, a tuple of + :class:`telegram.Message` objects is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + + data: JSONDict = {"user_id": user_id, "limit": limit} + + return Message.de_list( + await self._post( + "getUserPersonalChatMessages", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ), + self, + ) + async def send_paid_media( self, chat_id: str | int, @@ -12283,6 +12502,240 @@ async def save_prepared_keyboard_button( self, ) + async def send_live_photo( + self, + chat_id: int | str, + live_photo: "FileInput | LivePhoto", + photo: "FileInput | PhotoSize", + business_connection_id: str | None = None, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + has_spoiler: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + allow_paid_broadcast: bool | None = None, + message_effect_id: str | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "ReplyMarkup | None" = None, + *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + filename: str | None = 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 = None, + ) -> Message: + """ + Use this method to send live photos. + + .. seealso:: :wiki:`Working with Files and Media ` + + .. versionadded:: NEXT.VERSION + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + live_photo (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | \ + :obj:`bytes` | :class:`pathlib.Path` | :class:`telegram.LivePhoto`): Live photo + video to send. Pass a ``file_id`` to send a file that exists on the Telegram + servers (recommended). |uploadinputnopath| Sending live photos by a URL is + currently unsupported. Lastly you can pass an existing + :class:`telegram.LivePhoto` object to send. + + Caution: + * The video must be at most 10MB in size. + * The video duration must not exceed 10 seconds. + * If you pass a :class:`telegram.LivePhoto`, its + :attr:`~telegram.LivePhoto.photo` field will not be considered, use + :paramref:`photo` to specify the photo to send. + + photo (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` \ + | :class:`pathlib.Path` | :class:`telegram.PhotoSize`): The static photo to send. + Pass a ``file_id`` to send a file that exists on the Telegram servers (recommended) + . |uploadinputnopath| Sending live photos by a URL is currently unsupported. + Lastly you can pass an existing :class:`telegram.PhotoSize` object to send. + business_connection_id (:obj:`str`, optional): |business_id_str| + message_thread_id (:obj:`int`, optional): |message_thread_id_arg| + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + caption (:obj:`str`, optional): Video caption (may also be used when resending videos + by file_id), 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` + characters after entities parsing. + parse_mode (:obj:`str`, optional): |parse_mode| + caption_entities (Sequence[:class:`telegram.MessageEntity`], optional): + |caption_entities| + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| + has_spoiler (:obj:`bool`, optional): Pass :obj:`True` if the video needs to be covered + with a spoiler animation. + disable_notification (:obj:`bool`, optional): |disable_notification| + protect_content (:obj:`bool`, optional): |protect_content| + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + message_effect_id (:obj:`str`, optional): |message_effect_id| + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + reply_markup (:class:`InlineKeyboardMarkup` | :class:`ReplyKeyboardMarkup` | \ + :class:`ReplyKeyboardRemove` | :class:`ForceReply`, optional): + Additional interface options. An object for an inline keyboard, custom reply + keyboard, instructions to remove reply keyboard or to force a reply from the user. + + Keyword Args: + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for. + + filename (:obj:`str`, optional): Custom file name for :paramref:`photo`, when + uploading a new file. Convenience parameter, useful e.g. when sending files + generated by the :obj:`tempfile` module. + + Returns: + :class:`telegram.Message`: On success, the sent Message is returned. + + Raises: + :class:`telegram.error.TelegramError` + + """ + data: JSONDict = { + "chat_id": chat_id, + "live_photo": self._parse_file_input(live_photo, LivePhoto), + "photo": self._parse_file_input(photo, PhotoSize, filename=filename), + "has_spoiler": has_spoiler, + "show_caption_above_media": show_caption_above_media, + } + + return await self._send_message( + "sendLivePhoto", + data, + disable_notification=disable_notification, + reply_markup=reply_markup, + protect_content=protect_content, + message_thread_id=message_thread_id, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + reply_parameters=reply_parameters, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + allow_sending_without_reply=allow_sending_without_reply, + reply_to_message_id=reply_to_message_id, + ) + + async def delete_message_reaction( + self, + chat_id: int | str, + message_id: int, + user_id: int | None = None, + actor_chat_id: int | None = 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 = None, + ) -> bool: + """ + Use this method to remove a reaction from a message in a group or a supergroup chat. + The bot must have the :attr:`~telegram.ChatMemberAdministrator.can_delete_messages` + administrator right in the chat. + + .. versionadded:: NEXT.VERSION + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_group| + message_id (:obj:`int`): Identifier of the target message. + user_id (:obj:`int`, optional): Identifier of the user whose reaction will be removed, + if the reaction were added by a user. + actor_chat_id (:obj:`int`, optional): Identifier of the chat whose reaction will be + removed, if the reaction were added by a chat. + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "chat_id": chat_id, + "message_id": message_id, + "user_id": user_id, + "actor_chat_id": actor_chat_id, + } + + return await self._post( + "deleteMessageReaction", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def delete_all_message_reactions( + self, + chat_id: int | str, + user_id: int | None = None, + actor_chat_id: int | None = 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 = None, + ) -> bool: + """ + Use this method to remove up to ``10000`` recent reactions in a group or a supergroup chat + added by a given user or chat. The bot must have the + :attr:`~telegram.ChatMemberAdministrator.can_delete_messages` administrator right in the + chat. + + .. versionadded:: NEXT.VERSION + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_group| + user_id (:obj:`int`, optional): Identifier of the user whose reactions will be removed, + if the reactions were added by a user. + actor_chat_id (:obj:`int`, optional): Identifier of the chat whose reactions will be + removed, if the reactions were added by a chat. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "chat_id": chat_id, + "user_id": user_id, + "actor_chat_id": actor_chat_id, + } + + return await self._post( + "deleteAllMessageReactions", + 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, recursive: bool = True) -> JSONDict: # noqa: ARG002 """See :meth:`telegram.TelegramObject.to_dict`.""" data: JSONDict = {"id": self.id, "username": self.username, "first_name": self.first_name} @@ -12399,6 +12852,8 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """Alias for :meth:`answer_pre_checkout_query`""" answerWebAppQuery = answer_web_app_query """Alias for :meth:`answer_web_app_query`""" + answerGuestQuery = answer_guest_query + """Alias for :meth:`answer_guest_query`""" restrictChatMember = restrict_chat_member """Alias for :meth:`restrict_chat_member`""" promoteChatMember = promote_chat_member @@ -12629,3 +13084,15 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """Alias for :meth:`replace_managed_bot_token`""" savePreparedKeyboardButton = save_prepared_keyboard_button """Alias for :meth:`save_prepared_keyboard_button`""" + sendLivePhoto = send_live_photo + """Alias for :meth:`send_live_photo`""" + getManagedBotAccessSettings = get_managed_bot_access_settings + """Alias for :meth:`get_managed_bot_access_settings`""" + setManagedBotAccessSettings = set_managed_bot_access_settings + """Alias for :meth:`set_managed_bot_access_settings`""" + getUserPersonalChatMessages = get_user_personal_chat_messages + """Alias for :meth:`get_user_personal_chat_messages`""" + deleteMessageReaction = delete_message_reaction + """Alias for :meth:`delete_message_reaction`""" + deleteAllMessageReactions = delete_all_message_reactions + """Alias for :meth:`delete_all_message_reactions`""" diff --git a/src/telegram/_botaccesssettings.py b/src/telegram/_botaccesssettings.py new file mode 100644 index 00000000000..745d63ae67b --- /dev/null +++ b/src/telegram/_botaccesssettings.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 Access Settings.""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils.argumentparsing import de_list_optional, parse_sequence_arg +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class BotAccessSettings(TelegramObject): + """ + This object describes the access settings of a bot. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`is_access_restricted` and :attr:`added_users` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + is_access_restricted (:obj:`bool`): :obj:`True`, if only selected users can access the bot. + The bot's owner can always access it. + added_users (Sequence[:class:`telegram.User`], optional): The list of other users who + have access to the bot if the access is restricted. + + Attributes: + is_access_restricted (:obj:`bool`): :obj:`True`, if only selected users can access the bot. + The bot's owner can always access it. + added_users (Sequence[:class:`telegram.User`]): Optional. The list of other users who + have access to the bot if the access is restricted. + """ + + __slots__ = ("added_users", "is_access_restricted") + + def __init__( + self, + is_access_restricted: bool, + added_users: Sequence[User] | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.is_access_restricted: bool = is_access_restricted + self.added_users: tuple[User, ...] = parse_sequence_arg(added_users) + + self._id_attrs = (self.is_access_restricted, self.added_users) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "BotAccessSettings": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + data["added_users"] = de_list_optional(data.get("added_users"), User, bot) + + return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_chat.py b/src/telegram/_chat.py index 764da868e7e..d7b7a60c73b 100644 --- a/src/telegram/_chat.py +++ b/src/telegram/_chat.py @@ -58,12 +58,15 @@ InputChecklist, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, InputMediaPhoto, InputMediaVideo, InputPaidMedia, + InputPollMedia, InputPollOption, LabeledPrice, LinkPreviewOptions, + LivePhoto, Location, Message, MessageEntity, @@ -311,6 +314,7 @@ async def leave( async def get_administrators( self, + return_bots: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -334,6 +338,7 @@ async def get_administrators( """ return await self.get_bot().get_chat_administrators( chat_id=self.id, + return_bots=return_bots, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -1088,7 +1093,7 @@ async def send_message( async def send_message_draft( self, draft_id: int, - text: str, + text: str | None = None, message_thread_id: int | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, entities: Sequence["MessageEntity"] | None = None, @@ -1105,6 +1110,9 @@ async def send_message_draft( For the documentation of the arguments, please see :meth:`telegram.Bot.send_message_draft`. + .. versionchanged:: NEXT.VERSION + Bot API 10.0 makes the ``text`` argument optional. + Returns: :obj:`bool`: On success, :obj:`True` is returned. @@ -1190,7 +1198,7 @@ async def delete_messages( async def send_media_group( self, media: Sequence[ - "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo | InputMediaLivePhoto" # noqa: E501 # pylint: disable=line-too-long ], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, @@ -1349,6 +1357,76 @@ async def send_photo( suggested_post_parameters=suggested_post_parameters, ) + async def send_live_photo( + self, + live_photo: "FileInput | LivePhoto", + photo: "FileInput | PhotoSize", + business_connection_id: str | None = None, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + has_spoiler: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + allow_paid_broadcast: bool | None = None, + message_effect_id: str | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "ReplyMarkup | None" = None, + *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + filename: str | None = 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 = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_live_photo(update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_live_photo`. + + .. versionadded:: NEXT.VERSION + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_live_photo( + chat_id=self.id, + live_photo=live_photo, + photo=photo, + business_connection_id=business_connection_id, + message_thread_id=message_thread_id, + direct_messages_topic_id=direct_messages_topic_id, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + show_caption_above_media=show_caption_above_media, + has_spoiler=has_spoiler, + disable_notification=disable_notification, + protect_content=protect_content, + allow_paid_broadcast=allow_paid_broadcast, + message_effect_id=message_effect_id, + suggested_post_parameters=suggested_post_parameters, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + allow_sending_without_reply=allow_sending_without_reply, + reply_to_message_id=reply_to_message_id, + filename=filename, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def send_contact( self, phone_number: str | None = None, @@ -2303,8 +2381,12 @@ async def send_poll( allow_adding_options: bool | None = None, hide_results_until_closes: bool | None = None, description: str | None = None, - description_parse_mode: str | None = None, + description_parse_mode: ODVInput[str] | None = None, description_entities: Sequence["MessageEntity"] | None = None, + members_only: bool | None = None, + country_codes: Sequence[str] | None = None, + explanation_media: "InputPollMedia | None" = None, + media: "InputPollMedia | None" = None, *, reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, @@ -2363,6 +2445,10 @@ async def send_poll( description_entities=description_entities, hide_results_until_closes=hide_results_until_closes, allow_adding_options=allow_adding_options, + members_only=members_only, + country_codes=country_codes, + explanation_media=explanation_media, + media=media, ) async def send_copy( @@ -4049,6 +4135,82 @@ async def set_chat_member_tag( api_kwargs=api_kwargs, ) + async def delete_reaction( + self, + message_id: int, + user_id: int | None = None, + actor_chat_id: int | None = 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 = None, + ) -> bool: + """ + Shortcut for:: + + await bot.delete_message_reaction(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.delete_message_reaction`. + + .. versionadded:: NEXT.VERSION + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().delete_message_reaction( + chat_id=self.id, + message_id=message_id, + user_id=user_id, + actor_chat_id=actor_chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def delete_all_reactions( + self, + user_id: int | None = None, + actor_chat_id: int | None = 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 = None, + ) -> bool: + """ + Shortcut for:: + + await bot.delete_all_message_reactions( + chat_id=update.effective_chat.id, + *args, + **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.delete_all_message_reactions`. + + .. versionadded:: NEXT.VERSION + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().delete_all_message_reactions( + chat_id=self.id, + user_id=user_id, + actor_chat_id=actor_chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + class Chat(_ChatBase): """This object represents a chat. diff --git a/src/telegram/_chatmember.py b/src/telegram/_chatmember.py index be55c8469e8..8ad48616376 100644 --- a/src/telegram/_chatmember.py +++ b/src/telegram/_chatmember.py @@ -517,6 +517,10 @@ class ChatMemberRestricted(ChatMember): can_edit_tag (:obj:`bool`): :obj:`True`, if the user is allowed to edit their own tag. .. versionadded:: 22.7 + can_react_to_messages (:obj:`bool`): :obj:`True`, if the user is allowed to react to + messages. + + .. versionadded:: NEXT.VERSION tag (:obj:`str`, optional): Tag of the member. .. versionadded:: 22.7 @@ -573,6 +577,10 @@ class ChatMemberRestricted(ChatMember): can_edit_tag (:obj:`bool`): :obj:`True`, if the user is allowed to edit their own tag. .. versionadded:: 22.7 + can_react_to_messages (:obj:`bool`): :obj:`True`, if the user is allowed to react to + messages. + + .. versionadded:: NEXT.VERSION tag (:obj:`str`): Optional. Tag of the member. .. versionadded:: 22.7 @@ -586,6 +594,7 @@ class ChatMemberRestricted(ChatMember): "can_invite_users", "can_manage_topics", "can_pin_messages", + "can_react_to_messages", "can_send_audios", "can_send_documents", "can_send_messages", @@ -621,10 +630,17 @@ def __init__( can_send_voice_notes: bool, can_edit_tag: bool, tag: str | None = None, + # tags: NEXT.VERSION + # temporarily optional to make it not breaking + can_react_to_messages: bool | None = None, *, api_kwargs: JSONDict | None = None, ): super().__init__(status=ChatMember.RESTRICTED, user=user, api_kwargs=api_kwargs) + + if can_react_to_messages is None: + raise TypeError("`can_react_to_messages` is required and cannot be None") + with self._unfrozen(): self.is_member: bool = is_member self.can_change_info: bool = can_change_info @@ -643,6 +659,7 @@ def __init__( self.can_send_video_notes: bool = can_send_video_notes self.can_send_voice_notes: bool = can_send_voice_notes self.can_edit_tag: bool = can_edit_tag + self.can_react_to_messages: bool = can_react_to_messages self.tag: str | None = tag diff --git a/src/telegram/_chatpermissions.py b/src/telegram/_chatpermissions.py index 2116f4f4c33..7ca1871473d 100644 --- a/src/telegram/_chatpermissions.py +++ b/src/telegram/_chatpermissions.py @@ -36,7 +36,7 @@ class ChatPermissions(TelegramObject): :attr:`can_change_info`, :attr:`can_invite_users`, :attr:`can_pin_messages`, :attr:`can_send_audios`, :attr:`can_send_documents`, :attr:`can_send_photos`, :attr:`can_send_videos`, :attr:`can_send_video_notes`, :attr:`can_send_voice_notes`, - :attr:`can_manage_topics` and :attr:`can_edit_tag` are equal. + :attr:`can_manage_topics`, :attr:`can_edit_tag`, and :attr:`can_react_to_messages` are equal. .. versionchanged:: 20.0 :attr:`can_manage_topics` is considered as well when comparing objects of @@ -50,6 +50,9 @@ class ChatPermissions(TelegramObject): .. versionchanged:: 22.7 :attr:`can_edit_tag` is considered as well when comparing objects of this type in terms of equality. + .. versionchanged:: NEXT.VERSION + :attr:`can_react_to_messages` is considered as well when comparing objects of + this type in terms of equality. Note: @@ -100,6 +103,10 @@ class ChatPermissions(TelegramObject): tag. .. versionadded:: 22.7 + can_react_to_messages (:obj:`bool`, optional): :obj:`True`, if the user is allowed to react + to messages. If omitted, defaults to the value of :attr:`can_send_messages`. + + .. versionadded:: NEXT.VERSION Attributes: can_send_messages (:obj:`bool`): Optional. :obj:`True`, if the user is allowed to send text @@ -145,6 +152,10 @@ class ChatPermissions(TelegramObject): tag. .. versionadded:: 22.7 + can_react_to_messages (:obj:`bool`): Optional. :obj:`True`, if the user is allowed to react + to messages. If omitted, defaults to the value of :attr:`can_send_messages`. + + .. versionadded:: NEXT.VERSION """ @@ -155,6 +166,7 @@ class ChatPermissions(TelegramObject): "can_invite_users", "can_manage_topics", "can_pin_messages", + "can_react_to_messages", "can_send_audios", "can_send_documents", "can_send_messages", @@ -183,6 +195,7 @@ def __init__( can_send_video_notes: bool | None = None, can_send_voice_notes: bool | None = None, can_edit_tag: bool | None = None, + can_react_to_messages: bool | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -203,6 +216,7 @@ def __init__( self.can_send_video_notes: bool | None = can_send_video_notes self.can_send_voice_notes: bool | None = can_send_voice_notes self.can_edit_tag: bool | None = can_edit_tag + self.can_react_to_messages: bool | None = can_react_to_messages self._id_attrs = ( self.can_send_messages, @@ -220,6 +234,7 @@ def __init__( self.can_send_video_notes, self.can_send_voice_notes, self.can_edit_tag, + self.can_react_to_messages, ) self._freeze() diff --git a/src/telegram/_files/inputmedia.py b/src/telegram/_files/inputmedia.py index 23b6620985a..80886258414 100644 --- a/src/telegram/_files/inputmedia.py +++ b/src/telegram/_files/inputmedia.py @@ -16,7 +16,8 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. -"""Base class for Telegram InputMedia Objects.""" +"""Base classes for Telegram InputMedia, InputPaidMedia, InputPollMedia +and InputPollOptionMedia Objects.""" import datetime as dtm from collections.abc import Sequence @@ -28,6 +29,7 @@ from telegram._files.document import Document from telegram._files.inputfile import InputFile from telegram._files.photosize import PhotoSize +from telegram._files.sticker import Sticker from telegram._files.video import Video from telegram._messageentity import MessageEntity from telegram._telegramobject import TelegramObject @@ -37,17 +39,47 @@ from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.files import parse_file_input from telegram._utils.types import JSONDict, ODVInput, TimePeriod -from telegram.constants import InputMediaType +from telegram._utils.warnings import warn +from telegram.constants import BaseInputMediaType +from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: from telegram._utils.types import FileInput -MediaType: TypeAlias = Animation | Audio | Document | PhotoSize | Video +class _BaseInputMedia(TelegramObject): + """ + Base class for objects representing the various input media types. + + Args: + media_type (:obj:`str`): Type of media that the instance represents. + + Attributes: + type (:obj:`str`): Type of media that the instance represents. + """ + + __slots__ = ("type",) + + def __init__( + self, + media_type: str, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.type: str = enum.get_member(constants.BaseInputMediaType, media_type, media_type) -class InputMedia(TelegramObject): + +class InputMedia(_BaseInputMedia): """ - Base class for Telegram InputMedia Objects. + This object represents the content of a media message to be sent. It should be one of: + + * :class:`telegram.InputMediaAnimation` + * :class:`telegram.InputMediaAudio` + * :class:`telegram.InputMediaDocument` + * :class:`telegram.InputMediaLivePhoto` + * :class:`telegram.InputMediaPhoto` + * :class:`telegram.InputMediaVideo` .. versionchanged:: 20.0 Added arguments and attributes :attr:`type`, :attr:`media`, :attr:`caption`, @@ -85,7 +117,7 @@ class InputMedia(TelegramObject): """ - __slots__ = ("caption", "caption_entities", "media", "parse_mode", "type") + __slots__ = ("caption", "caption_entities", "media", "parse_mode") def __init__( self, @@ -97,14 +129,12 @@ def __init__( *, api_kwargs: JSONDict | None = None, ): - super().__init__(api_kwargs=api_kwargs) - self.type: str = enum.get_member(constants.InputMediaType, media_type, media_type) - self.media: str | InputFile = media - self.caption: str | None = caption - self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.parse_mode: ODVInput[str] = parse_mode - - self._freeze() + super().__init__(media_type=media_type, api_kwargs=api_kwargs) + with self._unfrozen(): + self.media: str | InputFile = media + self.caption: str | None = caption + self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) + self.parse_mode: ODVInput[str] = parse_mode @staticmethod def _parse_thumbnail_input(thumbnail: "FileInput | None") -> str | InputFile | None: @@ -123,6 +153,7 @@ class InputPaidMedia(TelegramObject): * :class:`telegram.InputPaidMediaPhoto` * :class:`telegram.InputPaidMediaVideo` + * :class:`telegram.InputPaidMediaLivePhoto` .. seealso:: :wiki:`Working with Files and Media ` @@ -142,6 +173,11 @@ class InputPaidMedia(TelegramObject): """:const:`telegram.constants.InputPaidMediaType.PHOTO`""" VIDEO: Final[str] = constants.InputPaidMediaType.VIDEO """:const:`telegram.constants.InputPaidMediaType.VIDEO`""" + LIVE_PHOTO: Final[str] = constants.InputPaidMediaType.LIVE_PHOTO + """:const:`telegram.constants.InputPaidMediaType.LIVE_PHOTO` + + .. versionadded:: NEXT.VERSION + """ __slots__ = ("media", "type") @@ -300,6 +336,51 @@ def duration(self) -> int | dtm.timedelta | None: return get_timedelta_value(self._duration, attribute="duration") +class InputPaidMediaLivePhoto(InputPaidMedia): + """ + The paid media to send is a live photo. + + .. seealso:: :wiki:`Working with Files and Media ` + + .. versionadded:: NEXT.VERSION + + Args: + media (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` \ + | :class:`pathlib.Path` | :class:`~telegram.Video`): Video of the live photo to send. + Pass a ``file_id`` to send a file that exists on the Telegram servers (recommended). + |uploadinputnopath| Sending live photos by a URL is currently unsupported. Lastly you + can pass an existing :class:`telegram.Video` object to send. + photo (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` | \ + :class:`pathlib.Path` | :class:`~telegram.PhotoSize`): Photo of the live photo to send. + Pass a ``file_id`` to send a file that exists on the Telegram servers (recommended). + |uploadinputnopath| Sending live photos by a URL is currently unsupported. + Lastly you can pass an existing :class:`telegram.PhotoSize` object to send. + + Attributes: + type (:obj:`str`): Type of the media, always + :tg-const:`telegram.constants.InputPaidMediaType.LIVE_PHOTO`. + media (:obj:`str` | :class:`telegram.InputFile`): Video of the live photo to send. + |fileinputnopath| + photo (:obj:`str` | :class:`telegram.InputFile`): Photo of the live photo to send. + |fileinputnopath| + """ + + __slots__ = ("photo",) + + def __init__( + self, + media: "FileInput | Video", + photo: "FileInput | PhotoSize", + *, + api_kwargs: JSONDict | None = None, + ): + media = parse_file_input(media, tg_type=Video, attach=True, local_mode=True) + photo = parse_file_input(photo, tg_type=PhotoSize, attach=True, local_mode=True) + super().__init__(type=InputPaidMedia.LIVE_PHOTO, media=media, api_kwargs=api_kwargs) + with self._unfrozen(): + self.photo: str | InputFile = photo + + class InputMediaAnimation(InputMedia): """Represents an animation file (GIF or H.264/MPEG-4 AVC video without sound) to be sent. @@ -320,11 +401,12 @@ class InputMediaAnimation(InputMedia): .. versionchanged:: 13.2 Accept :obj:`bytes` as input. - filename (:obj:`str`, optional): Custom file name for the animation, when uploading a - new file. Convenience parameter, useful e.g. when sending files generated by the - :obj:`tempfile` module. + filename_depr (:obj:`str`, optional): Positional placeholder for keyword only parameter + :paramref:`filename`. For backward compatibility. - .. versionadded:: 13.1 + .. versionadded:: NEXT.VERSION + .. deprecated:: NEXT.VERSION + This parameter is deprecated, use :paramref:`filename` instead. caption (:obj:`str`, optional): Caption of the animation to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -353,8 +435,17 @@ class InputMediaAnimation(InputMedia): .. versionadded:: 21.3 + Keyword Args: + filename (:obj:`str`, optional): Custom file name for the animation, when uploading a + new file. Convenience parameter, useful e.g. when sending files generated by the + :obj:`tempfile` module. + + .. versionadded:: 13.1 + .. versionchanged:: NEXT.VERSION + This parameter is now keyword-only. + Attributes: - type (:obj:`str`): :tg-const:`telegram.constants.InputMediaType.ANIMATION`. + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.ANIMATION`. media (:obj:`str` | :class:`telegram.InputFile`): Animation to send. caption (:obj:`str`): Optional. Caption of the animation to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters @@ -403,13 +494,28 @@ def __init__( height: int | None = None, duration: TimePeriod | None = None, caption_entities: Sequence[MessageEntity] | None = None, - filename: str | None = None, + # tag: deprecated NEXT.VERSION + filename_depr: str | None = None, + # - has_spoiler: bool | None = None, thumbnail: "FileInput | None" = None, show_caption_above_media: bool | None = None, *, + filename: str | None = None, api_kwargs: JSONDict | None = None, ): + if filename_depr is not None and filename is not None: + raise ValueError("`filename_depr` and `filename` are mutually exclusive.") + if filename_depr is not None: + warn( + PTBDeprecationWarning( + "NEXT.VERSION", + "Positional passing of `filename` or keyword usage of `filename_depr`" + " is deprecated. `filename` will become a keyword-only argument.", + ), + stacklevel=2, + ) + if isinstance(media, Animation): width = media.width if width is None else width height = media.height if height is None else height @@ -418,10 +524,13 @@ def __init__( else: # We use local_mode=True because we don't have access to the actual setting and want # things to work in local mode. - media = parse_file_input(media, filename=filename, attach=True, local_mode=True) + effective_filename = filename_depr or filename + media = parse_file_input( + media, filename=effective_filename, attach=True, local_mode=True + ) super().__init__( - InputMediaType.ANIMATION, + BaseInputMediaType.ANIMATION, media, caption, caption_entities, @@ -453,11 +562,12 @@ class InputMediaPhoto(InputMedia): .. versionchanged:: 13.2 Accept :obj:`bytes` as input. - filename (:obj:`str`, optional): Custom file name for the photo, when uploading a - new file. Convenience parameter, useful e.g. when sending files generated by the - :obj:`tempfile` module. + filename_depr (:obj:`str`, optional): Positional placeholder for keyword only parameter + :paramref:`filename`. For backward compatibility. - .. versionadded:: 13.1 + .. versionadded:: NEXT.VERSION + .. deprecated:: NEXT.VERSION + This parameter is deprecated, use :paramref:`filename` instead. caption (:obj:`str`, optional ): Caption of the photo to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -474,8 +584,17 @@ class InputMediaPhoto(InputMedia): .. versionadded:: 21.3 + Keyword Args: + filename (:obj:`str`, optional): Custom file name for the photo, when uploading a + new file. Convenience parameter, useful e.g. when sending files generated by the + :obj:`tempfile` module. + + .. versionadded:: 13.1 + .. versionchanged:: NEXT.VERSION + This parameter is now keyword-only. + Attributes: - type (:obj:`str`): :tg-const:`telegram.constants.InputMediaType.PHOTO`. + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.PHOTO`. media (:obj:`str` | :class:`telegram.InputFile`): Photo to send. caption (:obj:`str`): Optional. Caption of the photo to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters @@ -507,17 +626,35 @@ def __init__( caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, caption_entities: Sequence[MessageEntity] | None = None, - filename: str | None = None, + # tag: deprecated NEXT.VERSION + filename_depr: str | None = None, + # - has_spoiler: bool | None = None, show_caption_above_media: bool | None = None, *, + filename: str | None = None, api_kwargs: JSONDict | None = None, ): + if filename_depr is not None and filename is not None: + raise ValueError("`filename_depr` and `filename` are mutually exclusive.") + if filename_depr is not None: + warn( + PTBDeprecationWarning( + "NEXT.VERSION", + "Positional passing of `filename` or keyword usage of `filename_depr`" + " is deprecated. `filename` will become a keyword-only argument.", + ), + stacklevel=2, + ) + # We use local_mode=True because we don't have access to the actual setting and want # things to work in local mode. - media = parse_file_input(media, PhotoSize, filename=filename, attach=True, local_mode=True) + effective_filename = filename_depr or filename + media = parse_file_input( + media, PhotoSize, filename=effective_filename, attach=True, local_mode=True + ) super().__init__( - InputMediaType.PHOTO, + BaseInputMediaType.PHOTO, media, caption, caption_entities, @@ -553,11 +690,12 @@ class InputMediaVideo(InputMedia): .. versionchanged:: 13.2 Accept :obj:`bytes` as input. - filename (:obj:`str`, optional): Custom file name for the video, when uploading a - new file. Convenience parameter, useful e.g. when sending files generated by the - :obj:`tempfile` module. + filename_depr (:obj:`str`, optional): Positional placeholder for keyword only parameter + :paramref:`filename`. For backward compatibility. - .. versionadded:: 13.1 + .. versionadded:: NEXT.VERSION + .. deprecated:: NEXT.VERSION + This parameter is deprecated, use :paramref:`filename` instead. caption (:obj:`str`, optional): Caption of the video to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -594,8 +732,17 @@ class InputMediaVideo(InputMedia): .. versionadded:: 21.3 + Keyword Args: + filename (:obj:`str`, optional): Custom file name for the video, when uploading a + new file. Convenience parameter, useful e.g. when sending files generated by the + :obj:`tempfile` module. + + .. versionadded:: 13.1 + .. versionchanged:: NEXT.VERSION + This parameter is now keyword-only. + Attributes: - type (:obj:`str`): :tg-const:`telegram.constants.InputMediaType.VIDEO`. + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.VIDEO`. media (:obj:`str` | :class:`telegram.InputFile`): Video file to send. caption (:obj:`str`): Optional. Caption of the video to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters @@ -656,15 +803,30 @@ def __init__( supports_streaming: bool | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, caption_entities: Sequence[MessageEntity] | None = None, - filename: str | None = None, + # tag: deprecated NEXT.VERSION + filename_depr: str | None = None, + # - has_spoiler: bool | None = None, thumbnail: "FileInput | None" = None, show_caption_above_media: bool | None = None, cover: "FileInput | None" = None, start_timestamp: int | None = None, *, + filename: str | None = None, api_kwargs: JSONDict | None = None, ): + if filename_depr is not None and filename is not None: + raise ValueError("`filename_depr` and `filename` are mutually exclusive.") + if filename_depr is not None: + warn( + PTBDeprecationWarning( + "NEXT.VERSION", + "Positional passing of `filename` or keyword usage of `filename_depr`" + " is deprecated. `filename` will become a keyword-only argument.", + ), + stacklevel=2, + ) + if isinstance(media, Video): width = width if width is not None else media.width height = height if height is not None else media.height @@ -673,10 +835,13 @@ def __init__( else: # We use local_mode=True because we don't have access to the actual setting and want # things to work in local mode. - media = parse_file_input(media, filename=filename, attach=True, local_mode=True) + effective_filename = filename_depr or filename + media = parse_file_input( + media, filename=effective_filename, attach=True, local_mode=True + ) super().__init__( - InputMediaType.VIDEO, + BaseInputMediaType.VIDEO, media, caption, caption_entities, @@ -701,6 +866,156 @@ def duration(self) -> int | dtm.timedelta | None: return get_timedelta_value(self._duration, attribute="duration") +class InputMediaLocation(_BaseInputMedia): + """Represents a location to be sent. + + .. versionadded:: NEXT.VERSION + + Args: + latitude (:obj:`float`): Latitude of the location. + longitude (:obj:`float`): Longitude of the location. + horizontal_accuracy (:obj:`float`, optional): The radius of uncertainty for the location, + measured in meters; 0-:tg-const:`telegram.Location.HORIZONTAL_ACCURACY`. + + Attributes: + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.LOCATION`. + latitude (:obj:`float`): Latitude of the location. + longitude (:obj:`float`): Longitude of the location. + horizontal_accuracy (:obj:`float`): Optional. The radius of uncertainty for the location, + measured in meters; 0-:tg-const:`telegram.Location.HORIZONTAL_ACCURACY`. + """ + + __slots__ = ("horizontal_accuracy", "latitude", "longitude") + + def __init__( + self, + latitude: float, + longitude: float, + horizontal_accuracy: float | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(media_type=BaseInputMediaType.LOCATION, api_kwargs=api_kwargs) + with self._unfrozen(): + self.latitude: float = latitude + self.longitude: float = longitude + self.horizontal_accuracy: float | None = horizontal_accuracy + + +class InputMediaVenue(_BaseInputMedia): + """Represents a venue to be sent. + + .. versionadded:: NEXT.VERSION + + Args: + latitude (:obj:`float`): Latitude of the location. + longitude (:obj:`float`): Longitude of the location. + title (:obj:`str`): Name of the venue. + address (:obj:`str`): Address of the venue. + foursquare_id (:obj:`str`, optional): Foursquare identifier of the venue. + foursquare_type (:obj:`str`, optional): Foursquare type of the venue, if known. (For + example, ``“arts_entertainment/default”``, ``“arts_entertainment/aquarium”`` + or ``“food/icecream”``). + google_place_id (:obj:`str`, optional): Google Places identifier of the venue. + google_place_type (:obj:`str`, optional): Google Places type of the venue. (See\ + `supported types `__) + + Attributes: + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.VENUE`. + latitude (:obj:`float`): Latitude of the location. + longitude (:obj:`float`): Longitude of the location. + title (:obj:`str`): Name of the venue. + address (:obj:`str`): Address of the venue. + foursquare_id (:obj:`str`): Optional. Foursquare identifier of the venue. + foursquare_type (:obj:`str`): Optional. Foursquare type of the venue, if known. (For + example, ``“arts_entertainment/default”``, ``“arts_entertainment/aquarium”`` + or ``“food/icecream”``). + google_place_id (:obj:`str`): Optional. Google Places identifier of the venue. + google_place_type (:obj:`str`): Optional. Google Places type of the venue. (See\ + `supported types `__) + """ + + __slots__ = ( + "address", + "foursquare_id", + "foursquare_type", + "google_place_id", + "google_place_type", + "latitude", + "longitude", + "title", + ) + + def __init__( + self, + latitude: float, + longitude: float, + title: str, + address: str, + foursquare_id: str | None = None, + foursquare_type: str | None = None, + google_place_id: str | None = None, + google_place_type: str | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(media_type=BaseInputMediaType.VENUE, api_kwargs=api_kwargs) + with self._unfrozen(): + self.latitude: float = latitude + self.longitude: float = longitude + self.title: str = title + self.address: str = address + self.foursquare_id: str | None = foursquare_id + self.foursquare_type: str | None = foursquare_type + self.google_place_id: str | None = google_place_id + self.google_place_type: str | None = google_place_type + + +class InputMediaSticker(_BaseInputMedia): + """Represents a sticker file to be sent. + + .. seealso:: :wiki:`Working with Files and Media ` + + .. versionadded:: NEXT.VERSION + + Args: + media (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` | \ + :class:`pathlib.Path` | :class:`telegram.Sticker`): File to send. |fileinputnopath| + + Lastly you can pass an existing :class:`telegram.Sticker` object to send. + emoji (:obj:`str`, optional): Emoji associated with the sticker; only for just uploaded + stickers. + + Keyword Args: + filename (:obj:`str`, optional): Custom file name for the sticker, when uploading a + new file. Convenience parameter, useful e.g. when sending files generated by the + :obj:`tempfile` module. + + Attributes: + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.STICKER`. + media (:obj:`str` | :class:`telegram.InputFile`): Sticker file to send. + emoji (:obj:`str`): Optional. Emoji associated with the sticker; only for just uploaded + stickers. + """ + + __slots__ = ("emoji", "media") + + def __init__( + self, + media: "FileInput | Sticker", + emoji: str | None = None, + *, + filename: str | None = None, + api_kwargs: JSONDict | None = None, + ): + media = parse_file_input(media, Sticker, filename=filename, attach=True, local_mode=True) + + super().__init__(media_type=BaseInputMediaType.STICKER, api_kwargs=api_kwargs) + with self._unfrozen(): + self.media: str | InputFile = media + self.emoji: str | None = emoji + + class InputMediaAudio(InputMedia): """Represents an audio file to be treated as music to be sent. @@ -721,11 +1036,12 @@ class InputMediaAudio(InputMedia): .. versionchanged:: 13.2 Accept :obj:`bytes` as input. - filename (:obj:`str`, optional): Custom file name for the audio, when uploading a - new file. Convenience parameter, useful e.g. when sending files generated by the - :obj:`tempfile` module. + filename_depr (:obj:`str`, optional): Positional placeholder for keyword only parameter + :paramref:`filename`. For backward compatibility. - .. versionadded:: 13.1 + .. versionadded:: NEXT.VERSION + .. deprecated:: NEXT.VERSION + This parameter is deprecated, use :paramref:`filename` instead. caption (:obj:`str`, optional): Caption of the audio to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -748,8 +1064,17 @@ class InputMediaAudio(InputMedia): .. versionadded:: 20.2 + Keyword Args: + filename (:obj:`str`, optional): Custom file name for the audio, when uploading a + new file. Convenience parameter, useful e.g. when sending files generated by the + :obj:`tempfile` module. + + .. versionadded:: 13.1 + .. versionchanged:: NEXT.VERSION + This parameter is now keyword-only. + Attributes: - type (:obj:`str`): :tg-const:`telegram.constants.InputMediaType.AUDIO`. + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.AUDIO`. media (:obj:`str` | :class:`telegram.InputFile`): Audio file to send. caption (:obj:`str`): Optional. Caption of the audio to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters @@ -786,11 +1111,26 @@ def __init__( performer: str | None = None, title: str | None = None, caption_entities: Sequence[MessageEntity] | None = None, - filename: str | None = None, + # tag: deprecated NEXT.VERSION + filename_depr: str | None = None, + # - thumbnail: "FileInput | None" = None, *, + filename: str | None = None, api_kwargs: JSONDict | None = None, ): + if filename_depr is not None and filename is not None: + raise ValueError("`filename_depr` and `filename` are mutually exclusive.") + if filename_depr is not None: + warn( + PTBDeprecationWarning( + "NEXT.VERSION", + "Positional passing of `filename` or keyword usage of `filename_depr`" + " is deprecated. `filename` will become a keyword-only argument.", + ), + stacklevel=2, + ) + if isinstance(media, Audio): duration = duration if duration is not None else media._duration performer = media.performer if performer is None else performer @@ -799,10 +1139,13 @@ def __init__( else: # We use local_mode=True because we don't have access to the actual setting and want # things to work in local mode. - media = parse_file_input(media, filename=filename, attach=True, local_mode=True) + effective_filename = filename_depr or filename + media = parse_file_input( + media, filename=effective_filename, attach=True, local_mode=True + ) super().__init__( - InputMediaType.AUDIO, + BaseInputMediaType.AUDIO, media, caption, caption_entities, @@ -835,11 +1178,12 @@ class InputMediaDocument(InputMedia): .. versionchanged:: 13.2 Accept :obj:`bytes` as input. - filename (:obj:`str`, optional): Custom file name for the document, when uploading a - new file. Convenience parameter, useful e.g. when sending files generated by the - :obj:`tempfile` module. + filename_depr (:obj:`str`, optional): Positional placeholder for keyword only parameter + :paramref:`filename`. For backward compatibility. - .. versionadded:: 13.1 + .. versionadded:: NEXT.VERSION + .. deprecated:: NEXT.VERSION + This parameter is deprecated, use :paramref:`filename` instead. caption (:obj:`str`, optional): Caption of the document to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -857,8 +1201,17 @@ class InputMediaDocument(InputMedia): .. versionadded:: 20.2 + Keyword Args: + filename (:obj:`str`, optional): Custom file name for the document, when uploading a + new file. Convenience parameter, useful e.g. when sending files generated by the + :obj:`tempfile` module. + + .. versionadded:: 13.1 + .. versionchanged:: NEXT.VERSION + This parameter is now keyword-only. + Attributes: - type (:obj:`str`): :tg-const:`telegram.constants.InputMediaType.DOCUMENT`. + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.DOCUMENT`. media (:obj:`str` | :class:`telegram.InputFile`): File to send. caption (:obj:`str`): Optional. Caption of the document to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters @@ -887,17 +1240,35 @@ def __init__( parse_mode: ODVInput[str] = DEFAULT_NONE, disable_content_type_detection: bool | None = None, caption_entities: Sequence[MessageEntity] | None = None, - filename: str | None = None, + # tag: deprecated NEXT.VERSION + filename_depr: str | None = None, + # - thumbnail: "FileInput | None" = None, *, + filename: str | None = None, api_kwargs: JSONDict | None = None, ): + if filename_depr is not None and filename is not None: + raise ValueError("`filename_depr` and `filename` are mutually exclusive.") + if filename_depr is not None: + warn( + PTBDeprecationWarning( + "NEXT.VERSION", + "Positional passing of `filename` or keyword usage of `filename_depr`" + " is deprecated. `filename` will become a keyword-only argument.", + ), + stacklevel=2, + ) + # We use local_mode=True because we don't have access to the actual setting and want # things to work in local mode. - media = parse_file_input(media, Document, filename=filename, attach=True, local_mode=True) + effective_filename = filename_depr or filename + media = parse_file_input( + media, Document, filename=effective_filename, attach=True, local_mode=True + ) super().__init__( - InputMediaType.DOCUMENT, + BaseInputMediaType.DOCUMENT, media, caption, caption_entities, @@ -907,3 +1278,105 @@ def __init__( with self._unfrozen(): self.thumbnail: str | InputFile | None = self._parse_thumbnail_input(thumbnail) self.disable_content_type_detection: bool | None = disable_content_type_detection + + +class InputMediaLivePhoto(InputMedia): + """Represents a live photo to be sent. + + .. seealso:: :wiki:`Working with Files and Media ` + + .. versionadded:: NEXT.VERSION + + Args: + media (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` \ + | :class:`pathlib.Path` | :class:`~telegram.Video`): Video of the live photo to send. + Pass a ``file_id`` to send a file that exists on the Telegram servers (recommended). + |uploadinputnopath| Sending live photos by a URL is currently unsupported. Lastly + you can pass an existing :class:`telegram.Video` object to send. + photo (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` \ + | :class:`pathlib.Path` | :class:`~telegram.PhotoSize`): The static photo to send. + Pass a ``file_id`` to send a file that exists on the Telegram servers (recommended). + |uploadinputnopath| Sending live photos by a URL is currently unsupported. Lastly + you can pass an existing :class:`telegram.PhotoSize` object to send. + caption (:obj:`str`, optional): Caption of the live photo to be sent, + 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after + entities parsing. + parse_mode (:obj:`str`, optional): |parse_mode| + caption_entities (Sequence[:class:`telegram.MessageEntity`], optional): |caption_entities| + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| + has_spoiler (:obj:`bool`, optional): Pass :obj:`True`, if the video needs to be covered + with a spoiler animation. + + Attributes: + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.LIVE_PHOTO`. + media (:obj:`str` | :class:`telegram.InputFile`): Video of the live photo to send. + photo (:obj:`str` | :class:`telegram.InputFile`): The static photo to send. + caption (:obj:`str`): Optional. Caption of the live photo to be sent, + 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters + after entities parsing. + parse_mode (:obj:`str`): Optional. |parse_mode| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + show_caption_above_media (:obj:`bool`): Optional. |show_cap_above_med| + has_spoiler (:obj:`bool`): Optional. :obj:`True`, if the video is covered with a + spoiler animation. + """ + + __slots__ = ("has_spoiler", "photo", "show_caption_above_media") + + def __init__( + self, + media: "FileInput | Video", + photo: "FileInput | PhotoSize", + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence[MessageEntity] | None = None, + show_caption_above_media: bool | None = None, + has_spoiler: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + media = parse_file_input(media, tg_type=Video, attach=True, local_mode=True) + photo = parse_file_input(photo, tg_type=PhotoSize, attach=True, local_mode=True) + + super().__init__( + BaseInputMediaType.LIVE_PHOTO, + media, + caption, + caption_entities, + parse_mode, + api_kwargs=api_kwargs, + ) + with self._unfrozen(): + self.photo: str | InputFile = photo + self.show_caption_above_media: bool | None = show_caption_above_media + self.has_spoiler: bool | None = has_spoiler + + +InputPollMedia: TypeAlias = ( + InputMediaAnimation + | InputMediaAudio + | InputMediaDocument + | InputMediaLivePhoto + | InputMediaLocation + | InputMediaPhoto + | InputMediaVenue + | InputMediaVideo +) +"""Type alias for InputPollMedia objects. + +versionadded:: NEXT.VERSION +""" + +InputPollOptionMedia: TypeAlias = ( + InputMediaAnimation + | InputMediaLivePhoto + | InputMediaLocation + | InputMediaPhoto + | InputMediaSticker + | InputMediaVenue + | InputMediaVideo +) +"""Type alias for InputPollOptionMedia objects. + +.. versionadded:: NEXT.VERSION +""" diff --git a/src/telegram/_files/livephoto.py b/src/telegram/_files/livephoto.py new file mode 100644 index 00000000000..3700827fa6d --- /dev/null +++ b/src/telegram/_files/livephoto.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 LivePhoto.""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from telegram._files._basemedium import _BaseMedium +from telegram._files.photosize import PhotoSize +from telegram._utils.argumentparsing import de_list_optional, parse_sequence_arg, to_timedelta +from telegram._utils.types import JSONDict, TimePeriod + +if TYPE_CHECKING: + import datetime as dtm + + from telegram import Bot + + +class LivePhoto(_BaseMedium): + """ + This object represents a live photo. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`file_unique_id` is equal. + + .. versionadded:: NEXT.VERSION + + Args: + file_id (:obj:`str`): Identifier for the video file which can be used to download or reuse + the file. + file_unique_id (:obj:`str`): Unique identifier for this file, which + is supposed to be the same over time and for different bots. + Can't be used to download or reuse the file. + width (:obj:`int`): Video width as defined by the sender. + height (:obj:`int`): Video height as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the video + in seconds as defined by the sender. + photo (Sequence[:obj:`telegram.PhotoSize`], optional): Available sizes of the corresponding + static photo. + mime_type (:obj:`str`, optional): MIME type of a file as defined by the sender. + file_size (:obj:`int`, optional): File size in bytes. + + Attributes: + file_id (:obj:`str`): Identifier for the video file which can be used to download or reuse + the file. + file_unique_id (:obj:`str`): Unique identifier for this file, which + is supposed to be the same over time and for different bots. + Can't be used to download or reuse the file. + width (:obj:`int`): Video width as defined by the sender. + height (:obj:`int`): Video height as defined by the sender. + duration (:class:`datetime.timedelta`): Duration of the video + in seconds as defined by the sender. + photo (tuple[:obj:`telegram.PhotoSize`]): Optional. Available sizes of the corresponding + static photo. + mime_type (:obj:`str`): Optional. MIME type of a file as defined by the sender. + file_size (:obj:`int`): Optional. File size in bytes. + + """ + + __slots__ = ( + "duration", + "height", + "mime_type", + "photo", + "width", + ) + + def __init__( + self, + file_id: str, + file_unique_id: str, + width: int, + height: int, + duration: TimePeriod, + photo: Sequence[PhotoSize] | None = None, + mime_type: str | None = None, + file_size: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__( + file_id=file_id, + file_unique_id=file_unique_id, + file_size=file_size, + api_kwargs=api_kwargs, + ) + with self._unfrozen(): + # Required + self.width: int = width + self.height: int = height + self.duration: dtm.timedelta = to_timedelta(duration) + # Optional + self.photo: Sequence[PhotoSize] | None = parse_sequence_arg(photo) + self.mime_type: str | None = mime_type + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "LivePhoto": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["photo"] = de_list_optional(data.get("photo"), PhotoSize, bot) + + return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_message.py b/src/telegram/_message.py index 8c6626f50a6..5bc02a53ccc 100644 --- a/src/telegram/_message.py +++ b/src/telegram/_message.py @@ -37,6 +37,7 @@ from telegram._files.audio import Audio from telegram._files.contact import Contact from telegram._files.document import Document +from telegram._files.livephoto import LivePhoto from telegram._files.location import Location from telegram._files.photosize import PhotoSize from telegram._files.sticker import Sticker @@ -109,17 +110,21 @@ GiveawayCompleted, GiveawayCreated, GiveawayWinners, + InlineQueryResult, InputMedia, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, InputMediaPhoto, InputMediaVideo, InputPaidMedia, + InputPollMedia, InputPollOption, LabeledPrice, MessageId, MessageOrigin, ReactionType, + SentGuestMessage, SuggestedPostApprovalFailed, SuggestedPostApproved, SuggestedPostDeclined, @@ -707,6 +712,26 @@ class Message(MaybeInaccessibleMessage): managed_bot_created (:class:`telegram.ManagedBotCreated`, optional): Service message: user created a bot that will be managed by the current bot. + .. versionadded:: NEXT.VERSION + guest_bot_caller_user (:class:`telegram.User`, optional): For a message sent by a guest + bot, this is the user whose original message triggered the bot's response. + + .. versionadded:: NEXT.VERSION + guest_bot_caller_chat (:class:`telegram.Chat`, optional): For a message sent by a guest + bot, this is the chat whose original message triggered the bot's response. + + .. versionadded:: NEXT.VERSION + guest_query_id (:obj:`str`, optional): The unique identifier for the guest query. Use this + identifier with the method :meth:`telegram.Bot.answer_guest_query` to send a response + message. If non-empty, the message belongs to the chat where the guest bot was + summoned, which may not coincide with other existing bot chats sharing the same + identifier. + + .. versionadded:: NEXT.VERSION + live_photo (:class:`telegram.LivePhoto`, optional): Message is a live photo, information + about the live photo. For backward compatibility, when this field is set, the photo + field will also be set. + .. versionadded:: NEXT.VERSION Attributes: @@ -1139,6 +1164,26 @@ class Message(MaybeInaccessibleMessage): managed_bot_created (:class:`telegram.ManagedBotCreated`): Optional. Service message: user created a bot that will be managed by the current bot. + .. versionadded:: NEXT.VERSION + guest_bot_caller_user (:class:`telegram.User`): Optional. For a message sent by a guest + bot, this is the user whose original message triggered the bot's response. + + .. versionadded:: NEXT.VERSION + guest_bot_caller_chat (:class:`telegram.Chat`): Optional. For a message sent by a guest + bot, this is the chat whose original message triggered the bot's response. + + .. versionadded:: NEXT.VERSION + guest_query_id (:obj:`str`): Optional. The unique identifier for the guest query. Use this + identifier with the method :meth:`telegram.Bot.answer_guest_query` to send a response + message. If non-empty, the message belongs to the chat where the guest bot was + summoned, which may not coincide with other existing bot chats sharing the same + identifier. + + .. versionadded:: NEXT.VERSION + live_photo (:class:`telegram.LivePhoto`): Optional. Message is a live photo, information + about the live photo. For backward compatibility, when this field is set, the photo + field will also be set. + .. versionadded:: NEXT.VERSION .. |custom_emoji_no_md1_support| replace:: Since custom emoji entities are not supported by @@ -1201,6 +1246,9 @@ class Message(MaybeInaccessibleMessage): "giveaway_created", "giveaway_winners", "group_chat_created", + "guest_bot_caller_chat", + "guest_bot_caller_user", + "guest_query_id", "has_media_spoiler", "has_protected_content", "invoice", @@ -1210,6 +1258,7 @@ class Message(MaybeInaccessibleMessage): "is_topic_message", "left_chat_member", "link_preview_options", + "live_photo", "location", "managed_bot_created", "media_group_id", @@ -1380,6 +1429,10 @@ def __init__( poll_option_deleted: PollOptionDeleted | None = None, reply_to_poll_option_id: str | None = None, managed_bot_created: ManagedBotCreated | None = None, + guest_bot_caller_user: User | None = None, + guest_bot_caller_chat: Chat | None = None, + guest_query_id: str | None = None, + live_photo: LivePhoto | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -1514,6 +1567,10 @@ def __init__( self.poll_option_deleted: PollOptionDeleted | None = poll_option_deleted self.reply_to_poll_option_id: str | None = reply_to_poll_option_id self.managed_bot_created: ManagedBotCreated | None = managed_bot_created + self.guest_bot_caller_user: User | None = guest_bot_caller_user + self.guest_bot_caller_chat: Chat | None = guest_bot_caller_chat + self.guest_query_id: str | None = guest_query_id + self.live_photo: LivePhoto | None = live_photo self._effective_attachment = DEFAULT_NONE @@ -1743,6 +1800,13 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Message": data["managed_bot_created"] = de_json_optional( data.get("managed_bot_created"), ManagedBotCreated, bot ) + data["guest_bot_caller_user"] = de_json_optional( + data.get("guest_bot_caller_user"), User, bot + ) + data["guest_bot_caller_chat"] = de_json_optional( + data.get("guest_bot_caller_chat"), Chat, bot + ) + data["live_photo"] = de_json_optional(data.get("live_photo"), LivePhoto, bot) api_kwargs = {} # This is a deprecated field that TG still returns for backwards compatibility @@ -1774,6 +1838,7 @@ def effective_attachment( | Document | Game | Invoice + | LivePhoto | Location | PassportData | Sequence[PhotoSize] @@ -1798,6 +1863,7 @@ def effective_attachment( * :class:`telegram.Animation` * :class:`telegram.Game` * :class:`telegram.Invoice` + * :class:`telegram.LivePhoto` * :class:`telegram.Location` * :class:`telegram.PassportData` * list[:class:`telegram.PhotoSize`] @@ -2186,7 +2252,7 @@ async def reply_text( async def reply_text_draft( self, draft_id: int, - text: str, + text: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, entities: Sequence["MessageEntity"] | None = None, message_thread_id: ODVInput[int] = DEFAULT_NONE, @@ -2213,6 +2279,9 @@ async def reply_text_draft( .. versionadded:: 22.6 + .. versionchanged:: NEXT.VERSION + Bot API 10.0 makes the ``text`` argument optional. + Returns: :obj:`bool`: On success, :obj:`True` is returned. @@ -2485,7 +2554,7 @@ async def reply_html( async def reply_media_group( self, media: Sequence[ - "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo | InputMediaLivePhoto" # noqa: E501 # pylint: disable=line-too-long ], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, @@ -2646,6 +2715,87 @@ async def reply_photo( suggested_post_parameters=suggested_post_parameters, ) + async def reply_live_photo( + self, + live_photo: "FileInput | LivePhoto", + photo: "FileInput | PhotoSize", + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + has_spoiler: bool | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, + do_quote: bool | (_ReplyKwargs | None) = 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 = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_live_photo( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_live_photo`. + + .. versionadded:: NEXT.VERSION + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_live_photo( + chat_id=chat_id, + live_photo=live_photo, + photo=photo, + caption=caption, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + parse_mode=parse_mode, + caption_entities=caption_entities, + filename=filename, + protect_content=protect_content, + message_thread_id=message_thread_id, + has_spoiler=has_spoiler, + show_caption_above_media=show_caption_above_media, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + business_connection_id=self.business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + ) + async def reply_audio( self, audio: "FileInput | Audio", @@ -3535,8 +3685,12 @@ async def reply_poll( allow_adding_options: bool | None = None, hide_results_until_closes: bool | None = None, description: str | None = None, - description_parse_mode: str | None = None, + description_parse_mode: ODVInput[str] | None = None, description_entities: Sequence["MessageEntity"] | None = None, + members_only: bool | None = None, + country_codes: Sequence[str] | None = None, + explanation_media: "InputPollMedia | None" = None, + media: "InputPollMedia | None" = None, *, reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, @@ -3615,6 +3769,10 @@ async def reply_poll( description_entities=description_entities, hide_results_until_closes=hide_results_until_closes, allow_adding_options=allow_adding_options, + members_only=members_only, + country_codes=country_codes, + explanation_media=explanation_media, + media=media, ) async def reply_dice( @@ -3733,7 +3891,7 @@ async def reply_checklist( ) return await self.get_bot().send_checklist( business_connection_id=self.business_connection_id, - chat_id=chat_id, # type: ignore[arg-type] + chat_id=chat_id, checklist=checklist, disable_notification=disable_notification, reply_parameters=effective_reply_parameters, @@ -3845,7 +4003,7 @@ async def reply_game( ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_game( - chat_id=chat_id, # type: ignore[arg-type] + chat_id=chat_id, game_short_name=game_short_name, disable_notification=disable_notification, reply_parameters=effective_reply_parameters, @@ -5220,6 +5378,80 @@ async def decline_suggested_post( api_kwargs=api_kwargs, ) + async def delete_reaction( + self, + user_id: int | None = None, + actor_chat_id: int | None = 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 = None, + ) -> bool: + """Shortcut for:: + + await bot.delete_message_reaction( + chat_id=message.chat_id, + message_id=message.message_id, + *args, **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.delete_message_reaction`. + + .. versionadded:: NEXT.VERSION + + Returns: + :obj:`bool` On success, :obj:`True` is returned. + """ + return await self.get_bot().delete_message_reaction( + chat_id=self.chat_id, + message_id=self.message_id, + user_id=user_id, + actor_chat_id=actor_chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def answer_guest_query( + self, + result: "InlineQueryResult", + *, + 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 = None, + ) -> "SentGuestMessage": + """Shortcut for:: + + await bot.answer_guest_query( + self.guest_query_id, + *args, **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.answer_guest_query`. + + .. versionadded:: NEXT.VERSION + + Returns: + :class:`telegram.SentGuestMessage`: On success, a + :class:`telegram.SentGuestMessage` is returned. + """ + return await self.get_bot().answer_guest_query( + guest_query_id=self.guest_query_id, + result=result, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + def parse_entity(self, entity: MessageEntity) -> str: """Returns the text from a given :class:`telegram.MessageEntity`. diff --git a/src/telegram/_paidmedia.py b/src/telegram/_paidmedia.py index 67af46710a5..ecf6830cd99 100644 --- a/src/telegram/_paidmedia.py +++ b/src/telegram/_paidmedia.py @@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, Final from telegram import constants +from telegram._files.livephoto import LivePhoto from telegram._files.photosize import PhotoSize from telegram._files.video import Video from telegram._telegramobject import TelegramObject @@ -68,6 +69,8 @@ class PaidMedia(TelegramObject): """:const:`telegram.constants.PaidMediaType.PHOTO`""" VIDEO: Final[str] = constants.PaidMediaType.VIDEO """:const:`telegram.constants.PaidMediaType.VIDEO`""" + LIVE_PHOTO: Final[str] = constants.PaidMediaType.LIVE_PHOTO + """:const:`telegram.constants.PaidMediaType.LIVE_PHOTO`""" def __init__( self, @@ -100,6 +103,7 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PaidMedia": cls.PREVIEW: PaidMediaPreview, cls.PHOTO: PaidMediaPhoto, cls.VIDEO: PaidMediaVideo, + cls.LIVE_PHOTO: PaidMediaLivePhoto, } if cls is PaidMedia and data.get("type") in _class_mapping: @@ -251,6 +255,47 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PaidMediaVideo": return super().de_json(data=data, bot=bot) # type: ignore[return-value] +class PaidMediaLivePhoto(PaidMedia): + """ + The paid media is a live photo. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`live_photo` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + type (:obj:`str`): Type of the paid media, always :tg-const:`telegram.PaidMedia.LIVE_PHOTO` + live_photo (:class:`telegram.LivePhoto`): The photo. + + Attributes: + type (:obj:`str`): Type of the paid media, always :tg-const:`telegram.PaidMedia.LIVE_PHOTO` + live_photo (:class:`telegram.LivePhoto`): The photo. + + """ + + __slots__ = ("live_photo",) + + def __init__( + self, + live_photo: LivePhoto, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=PaidMedia.LIVE_PHOTO, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.live_photo: LivePhoto = live_photo + self._id_attrs = (self.type, self.live_photo) + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PaidMediaLivePhoto": + data = cls._parse_data(data) + + data["live_photo"] = de_json_optional(data.get("live_photo"), LivePhoto, bot) + return super().de_json(data=data, bot=bot) # type: ignore[return-value] + + class PaidMediaInfo(TelegramObject): """ Describes the paid media added to a message. diff --git a/src/telegram/_poll.py b/src/telegram/_poll.py index ba02062b33a..2f834403038 100644 --- a/src/telegram/_poll.py +++ b/src/telegram/_poll.py @@ -24,6 +24,15 @@ from telegram import constants from telegram._chat import Chat +from telegram._files.animation import Animation +from telegram._files.audio import Audio +from telegram._files.document import Document +from telegram._files.livephoto import LivePhoto +from telegram._files.location import Location +from telegram._files.photosize import PhotoSize +from telegram._files.sticker import Sticker +from telegram._files.venue import Venue +from telegram._files.video import Video from telegram._messageentity import MessageEntity from telegram._telegramobject import TelegramObject from telegram._user import User @@ -46,7 +55,122 @@ from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: - from telegram import Bot, MaybeInaccessibleMessage + from telegram import Bot, InputPollOptionMedia, MaybeInaccessibleMessage + + +class PollMedia(TelegramObject): + """ + At most one of the optional fields can be present in any given object. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if all of their attributes are equal. + + .. versionadded:: NEXT.VERSION + + Args: + animation (:class:`telegram.Animation`, optional): Media is an animation, information about + the animation + audio (:class:`telegram.Audio`, optional): Media is an audio file, information about the + file; currently, can't be received in a poll option + document (:class:`telegram.Document`, optional): Media is a general file, information about + the file; currently, can't be received in a poll option + live_photo (:class:`telegram.LivePhoto`, optional): Media is a live photo, information + about the live photo + location (:class:`telegram.Location`, optional): Media is a shared location, information + about the location + photo (Sequence[:class:`telegram.PhotoSize`], optional): Media is a photo, available sizes + of the photo + sticker (:class:`telegram.Sticker`, optional): Media is a sticker, information about the + sticker; currently, for poll options only + venue (:class:`telegram.Venue`, optional): Media is a venue, information about the venue + video (:class:`telegram.Video`, optional): Media is a video, information about the video + + Attributes: + animation (:class:`telegram.Animation`): Optional. Media is an animation, information about + the animation + audio (:class:`telegram.Audio`): Optional. Media is an audio file, information about the + file; currently, can't be received in a poll option + document (:class:`telegram.Document`): Optional. Media is a general file, information about + the file; currently, can't be received in a poll option + live_photo (:class:`telegram.LivePhoto`, optional): Media is a live photo, information + about the live photo + location (:class:`telegram.Location`): Optional. Media is a shared location, information + about the location + photo (tuple[:class:`telegram.PhotoSize`]): Optional. Media is a photo, available sizes + of the photo + sticker (:class:`telegram.Sticker`): Optional. Media is a sticker, information about the + sticker; currently, for poll options only + venue (:class:`telegram.Venue`): Optional. Media is a venue, information about the venue + video (:class:`telegram.Video`): Optional. Media is a video, information about the video + """ + + __slots__ = ( + "animation", + "audio", + "document", + "live_photo", + "location", + "photo", + "sticker", + "venue", + "video", + ) + + def __init__( + self, + animation: Animation | None = None, + audio: Audio | None = None, + document: Document | None = None, + live_photo: LivePhoto | None = None, + location: Location | None = None, + photo: Sequence[PhotoSize] | None = None, + sticker: Sticker | None = None, + venue: Venue | None = None, + video: Video | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.animation: Animation | None = animation + self.audio: Audio | None = audio + self.document: Document | None = document + self.live_photo: LivePhoto | None = live_photo + self.location: Location | None = location + self.photo: tuple[PhotoSize, ...] = parse_sequence_arg(photo) + self.sticker: Sticker | None = sticker + self.venue: Venue | None = venue + self.video: Video | None = video + + self._id_attrs = ( + self.animation, + self.audio, + self.document, + self.live_photo, + self.location, + self.photo, + self.sticker, + self.venue, + self.video, + ) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PollMedia": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["animation"] = de_json_optional(data.get("animation"), Animation, bot) + data["audio"] = de_json_optional(data.get("audio"), Audio, bot) + data["document"] = de_json_optional(data.get("document"), Document, bot) + data["live_photo"] = de_json_optional(data.get("live_photo"), LivePhoto, bot) + data["location"] = de_json_optional(data.get("location"), Location, bot) + data["photo"] = de_list_optional(data.get("photo"), PhotoSize, bot) + data["sticker"] = de_json_optional(data.get("sticker"), Sticker, bot) + data["venue"] = de_json_optional(data.get("venue"), Venue, bot) + data["video"] = de_json_optional(data.get("video"), Video, bot) + + return super().de_json(data=data, bot=bot) class InputPollOption(TelegramObject): @@ -69,6 +193,9 @@ class InputPollOption(TelegramObject): :paramref:`text_parse_mode`. Currently, only custom emoji entities are allowed. This list is empty if the text does not contain entities. + media (:class:`telegram.InputPollOptionMedia`, optional): Media added to the poll option. + + .. versionadded:: NEXT.VERSION Attributes: text (:obj:`str`): Option text, @@ -81,15 +208,19 @@ class InputPollOption(TelegramObject): :paramref:`text_parse_mode`. Currently, only custom emoji entities are allowed. This list is empty if the text does not contain entities. + media (:class:`telegram.InputPollOptionMedia`): Optional. Media added to the poll option. + + .. versionadded:: NEXT.VERSION """ - __slots__ = ("text", "text_entities", "text_parse_mode") + __slots__ = ("media", "text", "text_entities", "text_parse_mode") def __init__( self, text: str, text_parse_mode: ODVInput[str] = DEFAULT_NONE, text_entities: Sequence[MessageEntity] | None = None, + media: "InputPollOptionMedia | None" = None, *, api_kwargs: JSONDict | None = None, ): @@ -97,14 +228,30 @@ def __init__( self.text: str = text self.text_parse_mode: ODVInput[str] = text_parse_mode self.text_entities: tuple[MessageEntity, ...] = parse_sequence_arg(text_entities) + self.media: InputPollOptionMedia | None = media self._id_attrs = (self.text,) self._freeze() + # tags: deprecated NEXT.VERSION @classmethod def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "InputPollOption": - """See :meth:`telegram.TelegramObject.de_json`.""" + """See :meth:`telegram.TelegramObject.de_json`. The :paramref:`media` field will + not be included for deserialization. + + .. deprecated:: NEXT.VERSION + This class is input only and will be removed in the next version. + """ + warn( + PTBDeprecationWarning( + "NEXT.VERSION", + "`InputPollOption.de_json` is deprecated. This class is input only and will be " + "removed in the next version. The `media` field will not be included for " + "deserialization.", + ), + stacklevel=2, + ) data = cls._parse_data(data) data["text_entities"] = de_list_optional(data.get("text_entities"), MessageEntity, bot) @@ -117,7 +264,12 @@ class PollOption(TelegramObject): This object contains information about one answer option in a poll. Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal, if their :attr:`text` and :attr:`voter_count` are equal. + considered equal, if their :attr:`text`, :attr:`voter_count` and :attr:`persistent_id` + are equal. + + .. versionchanged:: NEXT.VERSION + Added attribute :attr:`persistent_id` to equality checks. + Args: persistent_id (:obj:`str`): Unique identifier of the option, persistent on option addition @@ -133,6 +285,9 @@ class PollOption(TelegramObject): poll option texts. .. versionadded:: 21.2 + media (:class:`telegram.PollMedia`, optional): Media added to the poll option. + + .. versionadded:: NEXT.VERSION added_by_user (:class:`telegram.User`, optional): User who added the option; omitted if the option wasn't added by a user after poll creation. @@ -161,6 +316,9 @@ class PollOption(TelegramObject): This list is empty if the question does not contain entities. .. versionadded:: 21.2 + media (:class:`telegram.PollMedia`): Optional. Media added to the poll option. + + .. versionadded:: NEXT.VERSION added_by_user (:class:`telegram.User`): Optional. User who added the option; omitted if the option wasn't added by a user after poll creation. @@ -179,6 +337,7 @@ class PollOption(TelegramObject): "added_by_chat", "added_by_user", "addition_date", + "media", "persistent_id", "text", "text_entities", @@ -193,23 +352,28 @@ def __init__( added_by_user: User | None = None, added_by_chat: Chat | None = None, addition_date: dtm.datetime | None = None, + media: PollMedia | None = None, # tags: required in NEXT.VERSION, bot api 9.6 # temporarily optional to avoid breaking changes persistent_id: str | None = None, *, api_kwargs: JSONDict | None = None, ): + if persistent_id is None: + raise TypeError("`persistent_id` is a required argument since Bot API 9.6") + super().__init__(api_kwargs=api_kwargs) self.text: str = text self.voter_count: int = voter_count self.added_by_user: User | None = added_by_user self.added_by_chat: Chat | None = added_by_chat self.addition_date: dtm.datetime | None = addition_date - self.persistent_id: str | None = persistent_id + self.persistent_id: str = persistent_id + self.media: PollMedia | None = media self.text_entities: tuple[MessageEntity, ...] = parse_sequence_arg(text_entities) - self._id_attrs = (self.text, self.voter_count) + self._id_attrs = (self.text, self.voter_count, self.persistent_id) self._freeze() @@ -225,6 +389,7 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PollOption": data["added_by_user"] = de_json_optional(data.get("added_by_user"), User, bot) data["added_by_chat"] = de_json_optional(data.get("added_by_chat"), Chat, bot) data["addition_date"] = from_timestamp(data.get("addition_date"), tzinfo=loc_tzinfo) + data["media"] = de_json_optional(data.get("media"), PollMedia, bot) return super().de_json(data=data, bot=bot) @@ -358,6 +523,9 @@ def __init__( *, api_kwargs: JSONDict | None = None, ): + if option_persistent_ids is None: + raise TypeError("`option_persistent_ids` is a required argument since Bot API 9.6") + super().__init__(api_kwargs=api_kwargs) self.poll_id: str = poll_id self.voter_chat: Chat | None = voter_chat @@ -637,6 +805,11 @@ class Poll(TelegramObject): is_anonymous (:obj:`bool`): :obj:`True`, if the poll is anonymous. type (:obj:`str`): Poll type, currently can be :attr:`REGULAR` or :attr:`QUIZ`. allows_multiple_answers (:obj:`bool`): :obj:`True`, if the poll allows multiple answers. + members_only (:obj:`bool`): :obj:`True`, if voting is limited to users who have been + members of the chat where the poll was originally sent for more than + :tg-const:`telegram.Poll.MIN_MEMBERSHIP_HOURS` hours. + + .. versionadded:: NEXT.VERSION correct_option_id (:obj:`int`, optional): A zero based identifier of the correct answer option. Available only for closed polls in the quiz mode, which were sent (not forwarded), by the bot or to a private chat with the bot. @@ -655,6 +828,10 @@ class Poll(TelegramObject): * This attribute is now always a (possibly empty) list and never :obj:`None`. * |sequenceclassargs| + explanation_media (:class:`telegram.PollMedia`, optional): Media added to the quiz + explanation. + + .. versionadded:: NEXT.VERSION open_period (:obj:`int` | :class:`datetime.timedelta`, optional): Amount of time in seconds the poll will be active after creation. @@ -678,6 +855,12 @@ class Poll(TelegramObject): the correct answer options. Available only for polls in quiz mode which are closed or were sent (not forwarded) by the bot or to the private chat with the bot. + .. versionadded:: NEXT.VERSION + country_codes (Sequence[:obj:`str`], optional): A list of two-letter ``ISO 3166-1 alpha-2`` + country codes indicating the countries from which users can vote in the poll. The + country code ``"FT"`` is used for users with anonymous numbers. If omitted, then users + from any country can participate in the poll. + .. versionadded:: NEXT.VERSION description (:obj:`str`, optional): Description of the poll; for polls inside the :class:`~telegram.Message` object only. @@ -686,6 +869,10 @@ class Poll(TelegramObject): description_entities (Sequence[:class:`telegram.MessageEntity`], optional): Special entities like usernames, URLs, bot commands, etc. that appear in the description + .. versionadded:: NEXT.VERSION + media (:class:`telegram.PollMedia`, optional): Media added to the poll description; + for polls inside the :class:`~telegram.Message` object only. + .. versionadded:: NEXT.VERSION Attributes: @@ -701,12 +888,11 @@ class Poll(TelegramObject): is_anonymous (:obj:`bool`): :obj:`True`, if the poll is anonymous. type (:obj:`str`): Poll type, currently can be :attr:`REGULAR` or :attr:`QUIZ`. allows_multiple_answers (:obj:`bool`): :obj:`True`, if the poll allows multiple answers. - correct_option_id (:obj:`int`): Optional. A zero based identifier of the correct answer - option. Available only for closed polls in the quiz mode, which were sent - (not forwarded), by the bot or to a private chat with the bot. + members_only (:obj:`bool`): :obj:`True`, if voting is limited to users who have been + members of the chat where the poll was originally sent for more than + :tg-const:`telegram.Poll.MIN_MEMBERSHIP_HOURS` hours. - .. deprecated:: NEXT.VERSION - Use :attr:`correct_option_ids` instead. + .. versionadded:: NEXT.VERSION explanation (:obj:`str`): Optional. Text that is shown when a user chooses an incorrect answer or taps on the lamp icon in a quiz-style poll, 0-:tg-const:`telegram.Poll.MAX_EXPLANATION_LENGTH` characters. @@ -719,6 +905,10 @@ class Poll(TelegramObject): .. versionchanged:: 20.0 This attribute is now always a (possibly empty) list and never :obj:`None`. + explanation_media (:class:`telegram.PollMedia`): Optional. Media added to the quiz + explanation. + + .. versionadded:: NEXT.VERSION open_period (:obj:`int` | :class:`datetime.timedelta`): Optional. Amount of time in seconds the poll will be active after creation. @@ -735,7 +925,7 @@ class Poll(TelegramObject): This list is empty if the question does not contain entities. .. versionadded:: 21.2 - allows_revoting (:obj:`bool`): Optional. :obj:`True`, if the poll + allows_revoting (:obj:`bool`): :obj:`True`, if the poll allows to change the chosenanswer options .. versionadded:: NEXT.VERSION @@ -743,6 +933,12 @@ class Poll(TelegramObject): correct answer options. Available only for polls in quiz mode which are closed or were sent (not forwarded) by the bot or to the private chat with the bot. + .. versionadded:: NEXT.VERSION + country_codes (tuple[:obj:`str`]): Optional. A list of two-letter ``ISO 3166-1 alpha-2`` + country codes indicating the countries from which users can vote in the poll. The + country code ``"FT"`` is used for users with anonymous numbers. If omitted, then users + from any country can participate in the poll. + .. versionadded:: NEXT.VERSION description (:obj:`str`): Optional. Description of the poll; for polls inside the Message object only @@ -751,6 +947,10 @@ class Poll(TelegramObject): description_entities (tuple[:class:`telegram.MessageEntity`]): Special entities like usernames, URLs, bot commands, etc. that appear in the description + .. versionadded:: NEXT.VERSION + media (:class:`telegram.PollMedia`): Optional. Media added to the poll description; + for polls inside the Message object only. + .. versionadded:: NEXT.VERSION """ @@ -761,13 +961,17 @@ class Poll(TelegramObject): "allows_revoting", "close_date", "correct_option_ids", + "country_codes", "description", "description_entities", "explanation", "explanation_entities", + "explanation_media", "id", "is_anonymous", "is_closed", + "media", + "members_only", "options", "question", "question_entities", @@ -788,6 +992,7 @@ def __init__( # tags: deprecated NEXT.VERSION # Removed in bot api 9.6: correct_option_id: int | None = None, + # --- explanation: str | None = None, explanation_entities: Sequence[MessageEntity] | None = None, open_period: TimePeriod | None = None, @@ -796,12 +1001,23 @@ def __init__( # tags: required in NEXT.VERSION # temporarily optional to avoid breaking changes allows_revoting: bool | None = None, + members_only: bool | None = None, + # --- correct_option_ids: Sequence[int] | None = None, description: str | None = None, description_entities: Sequence[MessageEntity] | None = None, + country_codes: Sequence[str] | None = None, + media: PollMedia | None = None, + explanation_media: PollMedia | None = None, *, api_kwargs: JSONDict | None = None, ): + if allows_revoting is None: + raise TypeError("`allows_revoting` is a required argument since Bot API 9.6") + + if members_only is None: + raise TypeError("`members_only` is a required argument since Bot API 10.0") + super().__init__(api_kwargs=api_kwargs) self.id: str = id self.question: str = question @@ -811,7 +1027,8 @@ def __init__( self.is_anonymous: bool = is_anonymous self.type: str = enum.get_member(constants.PollType, type, type) self.allows_multiple_answers: bool = allows_multiple_answers - self.allows_revoting: bool | None = allows_revoting + self.allows_revoting: bool = allows_revoting + self.members_only: bool = members_only # tag: deprecated NEXT.VERSION if correct_option_id is not None: @@ -838,6 +1055,9 @@ def __init__( self._open_period: dtm.timedelta | None = to_timedelta(open_period) self.close_date: dtm.datetime | None = close_date self.question_entities: tuple[MessageEntity, ...] = parse_sequence_arg(question_entities) + self.country_codes: tuple[str, ...] = parse_sequence_arg(country_codes) + self.media: PollMedia | None = media + self.explanation_media: PollMedia | None = explanation_media self._id_attrs = (self.id,) @@ -866,6 +1086,8 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Poll": data["description_entities"] = de_list_optional( data.get("description_entities"), MessageEntity, bot ) + data["media"] = de_json_optional(data.get("media"), PollMedia, bot) + data["explanation_media"] = de_json_optional(data.get("explanation_media"), PollMedia, bot) return super().de_json(data=data, bot=bot) @@ -1105,3 +1327,8 @@ def correct_option_id(self) -> int | None: .. versionadded:: NEXT.VERSION """ + MIN_MEMBERSHIP_HOURS: Final[int] = constants.PollLimit.MIN_MEMBERSHIP_HOURS + """:const:`telegram.constants.PollLimit.MIN_MEMBERSHIP_HOURS` + + .. versionadded:: NEXT.VERSION + """ diff --git a/src/telegram/_reply.py b/src/telegram/_reply.py index 6f1edfadeb8..a8e37cafacb 100644 --- a/src/telegram/_reply.py +++ b/src/telegram/_reply.py @@ -28,6 +28,7 @@ from telegram._files.audio import Audio from telegram._files.contact import Contact from telegram._files.document import Document +from telegram._files.livephoto import LivePhoto from telegram._files.location import Location from telegram._files.photosize import PhotoSize from telegram._files.sticker import Sticker @@ -114,6 +115,10 @@ class ExternalReplyInfo(TelegramObject): information about the paid media. .. versionadded:: 21.4 + live_photo (:class:`telegram.LivePhoto`, optional): Message is a live photo, information + about the live photo. + + .. versionadded:: NEXT.VERSION Attributes: origin (:class:`telegram.MessageOrigin`): Origin of the message replied to by the given @@ -166,6 +171,11 @@ class ExternalReplyInfo(TelegramObject): information about the paid media. .. versionadded:: 21.4 + live_photo (:class:`telegram.LivePhoto`): Optional. Message is a live photo, information + about the live photo. + + .. versionadded:: NEXT.VERSION + """ __slots__ = ( @@ -182,6 +192,7 @@ class ExternalReplyInfo(TelegramObject): "has_media_spoiler", "invoice", "link_preview_options", + "live_photo", "location", "message_id", "origin", @@ -223,6 +234,7 @@ def __init__( venue: Venue | None = None, paid_media: PaidMediaInfo | None = None, checklist: Checklist | None = None, + live_photo: LivePhoto | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -253,6 +265,7 @@ def __init__( self.poll: Poll | None = poll self.venue: Venue | None = venue self.paid_media: PaidMediaInfo | None = paid_media + self.live_photo: LivePhoto | None = live_photo self._id_attrs = (self.origin,) @@ -290,6 +303,7 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ExternalReplyInfo data["venue"] = de_json_optional(data.get("venue"), Venue, bot) data["paid_media"] = de_json_optional(data.get("paid_media"), PaidMediaInfo, bot) data["checklist"] = de_json_optional(data.get("checklist"), Checklist, bot) + data["live_photo"] = de_json_optional(data.get("live_photo"), LivePhoto, bot) return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_sentguestmessage.py b/src/telegram/_sentguestmessage.py new file mode 100644 index 00000000000..ecbc6ce6fe6 --- /dev/null +++ b/src/telegram/_sentguestmessage.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 Sent Guest Message.""" + +from telegram._telegramobject import TelegramObject +from telegram._utils.types import JSONDict + + +class SentGuestMessage(TelegramObject): + """Describes an inline message sent by a guest bot. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`inline_message_id` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + inline_message_id (:obj:`str`): Identifier of the sent inline message. + + Attributes: + inline_message_id (:obj:`str`): Identifier of the sent inline message. + """ + + __slots__ = ("inline_message_id",) + + def __init__( + self, + inline_message_id: str, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.inline_message_id: str = inline_message_id + + self._id_attrs = (self.inline_message_id,) + + self._freeze() diff --git a/src/telegram/_update.py b/src/telegram/_update.py index dc2a7f2f1e3..09c06896594 100644 --- a/src/telegram/_update.py +++ b/src/telegram/_update.py @@ -168,6 +168,11 @@ class Update(TelegramObject): managed by the bot, or token or owner of a managed bot was changed. .. versionadded:: NEXT.VERSION + guest_message (:class:`telegram.Message`, optional): New guest message. The bot can use + the field :attr:`telegram.Message.guest_query_id` and the method + :meth:`telegram.Bot.answer_guest_query` to send a message in response. + + .. versionadded:: NEXT.VERSION Attributes: @@ -284,6 +289,11 @@ class Update(TelegramObject): managed_bot (:class:`telegram.ManagedBotUpdated`): Optional. A new bot was created to be managed by the bot, or token or owner of a managed bot was changed. + .. versionadded:: NEXT.VERSION + guest_message (:class:`telegram.Message`): Optional. New guest message. The bot can use + the field :attr:`telegram.Message.guest_query_id` and the method + :meth:`telegram.Bot.answerGuestQuery` to send a message in response. + .. versionadded:: NEXT.VERSION """ @@ -304,6 +314,7 @@ class Update(TelegramObject): "edited_business_message", "edited_channel_post", "edited_message", + "guest_message", "inline_query", "managed_bot", "message", @@ -417,6 +428,11 @@ class Update(TelegramObject): MANAGED_BOT: Final[str] = constants.UpdateType.MANAGED_BOT """:const:`telegram.constants.UpdateType.MANAGED_BOT` + .. versionadded:: NEXT.VERSION + """ + GUEST_MESSAGE: Final[str] = constants.UpdateType.GUEST_MESSAGE + """:const:`telegram.constants.UpdateType.GUEST_MESSAGE` + .. versionadded:: NEXT.VERSION """ @@ -452,6 +468,7 @@ def __init__( deleted_business_messages: BusinessMessagesDeleted | None = None, purchased_paid_media: PaidMediaPurchased | None = None, managed_bot: ManagedBotUpdated | None = None, + guest_message: Message | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -483,6 +500,7 @@ def __init__( self.deleted_business_messages: BusinessMessagesDeleted | None = deleted_business_messages self.purchased_paid_media: PaidMediaPurchased | None = purchased_paid_media self.managed_bot: ManagedBotUpdated | None = managed_bot + self.guest_message: Message | None = guest_message self._effective_user: User | None = None self._effective_sender: User | Chat | None = None @@ -516,8 +534,8 @@ def effective_user(self) -> "User | None": This property now also considers :attr:`purchased_paid_media`. .. versionchanged:: NEXT.VERSION - This property now also considers :attr:`managed_bot`, :attr:`channel_post` - and :attr:`edited_channel_post`. + This property now also considers :attr:`managed_bot`, :attr:`guest_message`, + :attr:`channel_post`, and :attr:`edited_channel_post`. Example: * If :attr:`message` is present, this will give @@ -530,11 +548,14 @@ def effective_user(self) -> "User | None": user = None - if self.message: - user = self.message.from_user - - elif self.edited_message: - user = self.edited_message.from_user + if message := ( + self.message + or self.edited_message + or self.business_message + or self.edited_business_message + or self.guest_message + ): + user = message.from_user elif self.channel_post: user = self.channel_post.from_user @@ -572,12 +593,6 @@ def effective_user(self) -> "User | None": elif self.message_reaction: user = self.message_reaction.user - elif self.business_message: - user = self.business_message.from_user - - elif self.edited_business_message: - user = self.edited_business_message.from_user - elif self.business_connection: user = self.business_connection.user @@ -611,6 +626,9 @@ def effective_sender(self) -> "User | Chat | None": is present. + .. versionchanged:: NEXT.VERSION + This property now also considers :attr:`guest_message`. + Example: * If :attr:`message` is present, this will give either :attr:`telegram.Message.from_user` or :attr:`telegram.Message.sender_chat`. @@ -633,6 +651,7 @@ def effective_sender(self) -> "User | Chat | None": or self.edited_channel_post or self.business_message or self.edited_business_message + or self.guest_message ): sender = message.sender_chat @@ -664,6 +683,9 @@ def effective_chat(self) -> "Chat | None": This property now also considers :attr:`business_message`, :attr:`edited_business_message`, and :attr:`deleted_business_messages`. + .. versionchanged:: NEXT.VERSION + This property now also considers :attr:`guest_message`. + Example: If :attr:`message` is present, this will give :attr:`telegram.Message.chat`. @@ -673,21 +695,21 @@ def effective_chat(self) -> "Chat | None": chat = None - if self.message: - chat = self.message.chat - - elif self.edited_message: - chat = self.edited_message.chat + if message := ( + self.message + or self.edited_message + or self.channel_post + or self.edited_channel_post + or self.business_message + or self.edited_business_message + or self.deleted_business_messages + or self.guest_message + ): + chat = message.chat elif self.callback_query and self.callback_query.message: chat = self.callback_query.message.chat - elif self.channel_post: - chat = self.channel_post.chat - - elif self.edited_channel_post: - chat = self.edited_channel_post.chat - elif self.my_chat_member: chat = self.my_chat_member.chat @@ -709,15 +731,6 @@ def effective_chat(self) -> "Chat | None": elif self.message_reaction_count: chat = self.message_reaction_count.chat - elif self.business_message: - chat = self.business_message.chat - - elif self.edited_business_message: - chat = self.edited_business_message.chat - - elif self.deleted_business_messages: - chat = self.deleted_business_messages.chat - self._effective_chat = chat return chat @@ -734,6 +747,9 @@ def effective_message(self) -> Message | None: This property now also considers :attr:`business_message`, and :attr:`edited_business_message`. + .. versionchanged:: NEXT.VERSION + This property now also considers :attr:`guest_message`. + Tip: This property will only ever return objects of type :class:`telegram.Message` or :obj:`None`, never :class:`telegram.MaybeInaccessibleMessage` or @@ -782,6 +798,9 @@ def effective_message(self) -> Message | None: elif self.edited_business_message: message = self.edited_business_message + elif self.guest_message: + message = self.guest_message + self._effective_message = message return message @@ -838,5 +857,6 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Update": data.get("purchased_paid_media"), PaidMediaPurchased, bot ) data["managed_bot"] = de_json_optional(data.get("managed_bot"), ManagedBotUpdated, bot) + data["guest_message"] = de_json_optional(data.get("guest_message"), Message, bot) return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_user.py b/src/telegram/_user.py index 821f230adb6..022faa9584f 100644 --- a/src/telegram/_user.py +++ b/src/telegram/_user.py @@ -42,17 +42,21 @@ from telegram import ( Animation, Audio, + BotAccessSettings, Contact, Document, Gift, InlineKeyboardMarkup, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, InputMediaPhoto, InputMediaVideo, + InputPollMedia, InputPollOption, LabeledPrice, LinkPreviewOptions, + LivePhoto, Location, Message, MessageEntity, @@ -128,6 +132,11 @@ class User(TelegramObject): can_manage_bots (:obj:`bool`, optional): :obj:`True`, if other bots can be created to be controlled by the bot. Returned only in :meth:`telegram.Bot.get_me`. + .. versionadded:: NEXT.VERSION + supports_guest_queries (:obj:`bool`, optional): :obj:`True`, if the bot supports guest + queries from chats it is not a member of. Returned only in + :meth:`telegram.Bot.get_me`. + .. versionadded:: NEXT.VERSION Attributes: @@ -172,6 +181,11 @@ class User(TelegramObject): can_manage_bots (:obj:`bool`): Optional. :obj:`True`, if other bots can be created to be controlled by the bot. Returned only in :meth:`telegram.Bot.get_me`. + .. versionadded:: NEXT.VERSION + supports_guest_queries (:obj:`bool`): Optional. :obj:`True`, if the bot supports guest + queries from chats it is not a member of. Returned only in + :meth:`telegram.Bot.get_me`. + .. versionadded:: NEXT.VERSION .. |user_chat_id_note| replace:: This shortcuts build on the assumption that :attr:`User.id` @@ -194,6 +208,7 @@ class User(TelegramObject): "is_premium", "language_code", "last_name", + "supports_guest_queries", "supports_inline_queries", "username", ) @@ -216,6 +231,7 @@ def __init__( has_topics_enabled: bool | None = None, allows_users_to_create_topics: bool | None = None, can_manage_bots: bool | None = None, + supports_guest_queries: bool | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -238,6 +254,7 @@ def __init__( self.has_topics_enabled: bool | None = has_topics_enabled self.allows_users_to_create_topics: bool | None = allows_users_to_create_topics self.can_manage_bots: bool | None = can_manage_bots + self.supports_guest_queries: bool | None = supports_guest_queries self._id_attrs = (self.id,) @@ -528,7 +545,7 @@ async def send_message( async def send_message_draft( self, draft_id: int, - text: str, + text: str | None = None, message_thread_id: int | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, entities: Sequence["MessageEntity"] | None = None, @@ -550,6 +567,9 @@ async def send_message_draft( .. versionadded:: 22.6 + .. versionchanged:: NEXT.VERSION + Bot API 10.0 makes the ``text`` argument optional. + Returns: :obj:`bool`: On success, :obj:`True` is returned. @@ -701,10 +721,83 @@ async def send_photo( suggested_post_parameters=suggested_post_parameters, ) + async def send_live_photo( + self, + live_photo: "FileInput | LivePhoto", + photo: "FileInput | PhotoSize", + business_connection_id: str | None = None, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + has_spoiler: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + allow_paid_broadcast: bool | None = None, + message_effect_id: str | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "ReplyMarkup | None" = None, + *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + filename: str | None = 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 = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_live_photo(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_live_photo`. + + .. versionadded:: NEXT.VERSION + + Note: + |user_chat_id_note| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_live_photo( + chat_id=self.id, + live_photo=live_photo, + photo=photo, + business_connection_id=business_connection_id, + message_thread_id=message_thread_id, + direct_messages_topic_id=direct_messages_topic_id, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + show_caption_above_media=show_caption_above_media, + has_spoiler=has_spoiler, + disable_notification=disable_notification, + protect_content=protect_content, + allow_paid_broadcast=allow_paid_broadcast, + message_effect_id=message_effect_id, + suggested_post_parameters=suggested_post_parameters, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + allow_sending_without_reply=allow_sending_without_reply, + reply_to_message_id=reply_to_message_id, + filename=filename, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def send_media_group( self, media: Sequence[ - "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo | InputMediaLivePhoto" # noqa: E501 # pylint: disable=line-too-long ], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, @@ -1748,8 +1841,12 @@ async def send_poll( allow_adding_options: bool | None = None, hide_results_until_closes: bool | None = None, description: str | None = None, - description_parse_mode: str | None = None, + description_parse_mode: ODVInput[str] | None = None, description_entities: Sequence["MessageEntity"] | None = None, + members_only: bool | None = None, + country_codes: Sequence[str] | None = None, + explanation_media: "InputPollMedia | None" = None, + media: "InputPollMedia | None" = None, *, reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, @@ -1811,6 +1908,10 @@ async def send_poll( description_entities=description_entities, hide_results_until_closes=hide_results_until_closes, allow_adding_options=allow_adding_options, + members_only=members_only, + country_codes=country_codes, + explanation_media=explanation_media, + media=media, ) async def send_gift( @@ -2761,7 +2862,7 @@ async def replace_token( .. versionadded:: NEXT.VERSION Returns: - :obj:`bool`: On success, :obj:`str` is returned. + :obj:`str`: On success, :obj:`str` is returned. """ return await self.get_bot().replace_managed_bot_token( user_id=self.id, @@ -2771,3 +2872,196 @@ async def replace_token( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) + + async def get_managed_bot_access_settings( + self, + *, + 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 = None, + ) -> "BotAccessSettings": + """ + Shortcut for:: + + await bot.get_managed_bot_access_settings( + user_id=update.effective_user.id, + *args, **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.get_managed_bot_access_settings`. + + .. versionadded:: NEXT.VERSION + + Returns: + :class:`telegram.BotAccessSettings`: On success, returns the access settings of the bot + managed by the user. + """ + + return await self.get_bot().get_managed_bot_access_settings( + user_id=self.id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def set_managed_bot_access_settings( + self, + is_access_restricted: bool, + added_user_ids: Sequence[int] | None = 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 = None, + ) -> bool: + """ + Shortcut for:: + + await bot.set_managed_bot_access_settings( + user_id=update.effective_user.id, + *args, **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.set_managed_bot_access_settings`. + + .. versionadded:: NEXT.VERSION + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + + return await self.get_bot().set_managed_bot_access_settings( + user_id=self.id, + is_access_restricted=is_access_restricted, + added_user_ids=added_user_ids, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def delete_reaction( + self, + chat_id: int | str, + message_id: int, + actor_chat_id: int | None = 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 = None, + ) -> bool: + """ + Shortcut for:: + + await bot.delete_message_reaction( + user_id=update.effective_user.id, + *args, + **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.delete_message_reaction`. + + .. versionadded:: NEXT.VERSION + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().delete_message_reaction( + user_id=self.id, + chat_id=chat_id, + message_id=message_id, + actor_chat_id=actor_chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def get_personal_chat_messages( + self, + limit: int, + *, + 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 = None, + ) -> tuple["Message", ...]: + """ + Shortcut for:: + + await bot.get_user_personal_chat_messages( + user_id=update.effective_user.id, + *args, **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.get_user_personal_chat_messages`. + + .. versionadded:: NEXT.VERSION + + Returns: + tuple[:class:`telegram.Message`]: On success, a tuple of messages from the personal + channel chat is returned. + """ + + return await self.get_bot().get_user_personal_chat_messages( + user_id=self.id, + limit=limit, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def delete_all_reactions( + self, + chat_id: int | str, + actor_chat_id: int | None = 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 = None, + ) -> bool: + """ + Shortcut for:: + + await bot.delete_all_message_reactions( + user_id=update.effective_user.id, + *args, + **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.delete_all_message_reactions`. + + .. versionadded:: NEXT.VERSION + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().delete_all_message_reactions( + chat_id=chat_id, + user_id=self.id, + actor_chat_id=actor_chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) diff --git a/src/telegram/constants.py b/src/telegram/constants.py index 144e6763892..0bcd88e8f2c 100644 --- a/src/telegram/constants.py +++ b/src/telegram/constants.py @@ -45,6 +45,7 @@ "BackgroundFillType", "BackgroundTypeLimit", "BackgroundTypeType", + "BaseInputMediaType", "BotCommandLimit", "BotCommandScopeType", "BotDescriptionLimit", @@ -87,6 +88,7 @@ "KeyboardButtonRequestUsersLimit", "KeyboardButtonStyle", "LocationLimit", + "ManagedBotAccessLimit", "MaskPosition", "MediaGroupLimit", "MenuButtonType", @@ -101,6 +103,7 @@ "OwnedGiftType", "PaidMediaType", "ParseMode", + "PersonalChatMessagesLimit", "PollLimit", "PollType", "PollingLimit", @@ -181,7 +184,7 @@ class _AccentColor(NamedTuple): #: :data:`telegram.__bot_api_version_info__`. #: #: .. versionadded:: 20.0 -BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=9, minor=6) +BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=10, minor=0) #: :obj:`str`: Telegram Bot API #: version supported by this version of `python-telegram-bot`. Also available as #: :data:`telegram.__bot_api_version__`. @@ -1519,10 +1522,43 @@ class InputChecklistLimit(IntEnum): """ +class BaseInputMediaType(StringEnum): + """This enum contains the available types of :class:`telegram.InputMedia`, + :class:`telegram.InputPollMedia` and :class:`telegram.InputPollOptionMedia`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: NEXT.VERSION + """ + + __slots__ = () + + ANIMATION = "animation" + """:obj:`str`: Type of :class:`telegram.InputMediaAnimation`.""" + DOCUMENT = "document" + """:obj:`str`: Type of :class:`telegram.InputMediaDocument`.""" + AUDIO = "audio" + """:obj:`str`: Type of :class:`telegram.InputMediaAudio`.""" + PHOTO = "photo" + """:obj:`str`: Type of :class:`telegram.InputMediaPhoto`.""" + VIDEO = "video" + """:obj:`str`: Type of :class:`telegram.InputMediaVideo`.""" + LOCATION = "location" + """:obj:`str`: Type of :class:`telegram.InputMediaLocation`.""" + STICKER = "sticker" + """:obj:`str`: Type of :class:`telegram.InputMediaSticker`.""" + VENUE = "venue" + """:obj:`str`: Type of :class:`telegram.InputMediaVenue`.""" + LIVE_PHOTO = "live_photo" + """:obj:`str`: Type of :class:`telegram.InputMediaLivePhoto`.""" + + class InputMediaType(StringEnum): """This enum contains the available types of :class:`telegram.InputMedia`. The enum members of this enumeration are instances of :class:`str` and can be treated as such. + .. deprecated:: NEXT.VERSION + Use :class:`telegram.constants.BaseInputMediaType` instead. + .. versionadded:: 20.0 """ @@ -1538,6 +1574,11 @@ class InputMediaType(StringEnum): """:obj:`str`: Type of :class:`telegram.InputMediaPhoto`.""" VIDEO = "video" """:obj:`str`: Type of :class:`telegram.InputMediaVideo`.""" + LIVE_PHOTO = "live_photo" + """:obj:`str`: Type of :class:`telegram.InputMediaLivePhoto`. + + .. versionadded:: NEXT.VERSION + """ class InputPaidMediaType(StringEnum): @@ -1550,9 +1591,14 @@ class InputPaidMediaType(StringEnum): __slots__ = () PHOTO = "photo" - """:obj:`str`: Type of :class:`telegram.InputMediaPhoto`.""" + """:obj:`str`: Type of :class:`telegram.InputPaidMediaPhoto`.""" VIDEO = "video" - """:obj:`str`: Type of :class:`telegram.InputMediaVideo`.""" + """:obj:`str`: Type of :class:`telegram.InputPaidMediaVideo`.""" + LIVE_PHOTO = "live_photo" + """:obj:`str`: Type of :class:`telegram.InputPaidMediaLivePhoto`. + + .. versionadded:: NEXT.VERSION + """ class InputProfilePhotoType(StringEnum): @@ -1784,6 +1830,8 @@ class LocationLimit(IntEnum): :meth:`telegram.Bot.edit_message_live_location` * :paramref:`~telegram.Bot.send_location.horizontal_accuracy` parameter of :meth:`telegram.Bot.send_location` + * :paramref:`~telegram.InputMediaLocation.horizontal_accuracy` parameter of + :class:`telegram.InputMediaLocation` """ MIN_HEADING = 1 @@ -1963,6 +2011,11 @@ class MessageAttachmentType(StringEnum): """:obj:`str`: Messages with :attr:`telegram.Message.game`.""" INVOICE = "invoice" """:obj:`str`: Messages with :attr:`telegram.Message.invoice`.""" + LIVE_PHOTO = "live_photo" + """:obj:`str`: Messages with :attr:`telegram.Message.live_photo`. + + .. versionadded:: NEXT.VERSION + """ LOCATION = "location" """:obj:`str`: Messages with :attr:`telegram.Message.location`.""" PAID_MEDIA = "paid_media" @@ -2345,6 +2398,11 @@ class MessageType(StringEnum): """:obj:`str`: Messages with :attr:`telegram.Message.invoice`.""" LEFT_CHAT_MEMBER = "left_chat_member" """:obj:`str`: Messages with :attr:`telegram.Message.left_chat_member`.""" + LIVE_PHOTO = "live_photo" + """:obj:`str`: Messages with :attr:`telegram.Message.live_photo`. + + .. versionadded:: NEXT.VERSION + """ LOCATION = "location" """:obj:`str`: Messages with :attr:`telegram.Message.location`.""" MANAGED_BOT_CREATED = "managed_bot_created" @@ -2566,6 +2624,33 @@ class PaidMediaType(StringEnum): """:obj:`str`: The type of :class:`telegram.PaidMediaVideo`.""" PHOTO = "photo" """:obj:`str`: The type of :class:`telegram.PaidMediaPhoto`.""" + LIVE_PHOTO = "live_photo" + """:obj:`str`: The type of :class:`telegram.PaidMediaLivePhoto` + + .. versionadded:: NEXT.VERSION + """ + + +class PersonalChatMessagesLimit(IntEnum): + """This enum contains limitations for + :paramref:`telegram.Bot.get_user_personal_chat_messages.limit`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: NEXT.VERSION + """ + + __slots__ = () + + MIN_LIMIT = 1 + """:obj:`int`: Minimum value allowed for the + :paramref:`~telegram.Bot.get_user_personal_chat_messages.limit` + parameter of :meth:`telegram.Bot.get_user_personal_chat_messages`. + """ + MAX_LIMIT = 20 + """:obj:`int`: Maximum value allowed for the + :paramref:`~telegram.Bot.get_user_personal_chat_messages.limit` + parameter of :meth:`telegram.Bot.get_user_personal_chat_messages`. + """ class PollingLimit(IntEnum): @@ -3422,10 +3507,13 @@ class PollLimit(IntEnum): to the :paramref:`~telegram.Bot.send_poll.options` parameter of :meth:`telegram.Bot.send_poll`. """ - MIN_OPTION_NUMBER = 2 + MIN_OPTION_NUMBER = 1 """:obj:`int`: Minimum number of strings passed in a :obj:`list` to the :paramref:`~telegram.Bot.send_poll.options` parameter of :meth:`telegram.Bot.send_poll`. + + .. versionchanged:: NEXT.VERSION + Bot API 10.0 decreased this value from ``2`` to ``1``. """ MAX_OPTION_NUMBER = 12 """:obj:`int`: Maximum number of strings passed in a :obj:`list` @@ -3466,6 +3554,19 @@ class PollLimit(IntEnum): .. versionadded:: NEXT.VERSION """ + MIN_MEMBERSHIP_HOURS = 24 + """:obj:`int`: Minimum number of hours a user must have been a member of the chat + before they can vote in a members-only poll. + + .. versionadded:: NEXT.VERSION + """ + MAX_COUNTRY_CODES = 12 + """:obj:`int`: Maximum number of two-letter ``ISO 3166-1 alpha-2`` country codes passed in a + :obj:`list` to the :paramref:`~telegram.Bot.send_poll.country_codes` parameter of + :meth:`telegram.Bot.send_poll`. + + .. versionadded:: NEXT.VERSION + """ class PollType(StringEnum): @@ -3620,6 +3721,11 @@ class UpdateType(StringEnum): .. versionadded:: NEXT.VERSION """ + GUEST_MESSAGE = "guest_message" + """:obj:`str`: Updates with :attr:`telegram.Update.guest_message`. + + .. versionadded:: NEXT.VERSION + """ class InvoiceLimit(IntEnum): @@ -4039,6 +4145,22 @@ class ReactionEmoji(StringEnum): """:obj:`str`: Pouting face""" +class ManagedBotAccessLimit(IntEnum): + """This enum contains limitations for :meth:`~telegram.Bot.set_managed_bot_access_settings`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: NEXT.VERSION + """ + + __slots__ = () + + MAX_ALLOWED_USERS = 10 + """:obj:`int`: Maximum number of users that can be allowed to access a managed bot in the + :paramref:`~telegram.Bot.set_managed_bot_access_settings.added_user_ids` parameter of + :meth:`~telegram.Bot.set_managed_bot_access_settings`. + """ + + class VerifyLimit(IntEnum): """This enum contains limitations for :meth:`~telegram.Bot.verify_chat` and :meth:`~telegram.Bot.verify_user`. diff --git a/src/telegram/ext/_extbot.py b/src/telegram/ext/_extbot.py index 931115f7beb..0d8ef596e50 100644 --- a/src/telegram/ext/_extbot.py +++ b/src/telegram/ext/_extbot.py @@ -38,6 +38,7 @@ Animation, Audio, Bot, + BotAccessSettings, BotCommand, BotCommandScope, BotDescription, @@ -76,6 +77,7 @@ PreparedKeyboardButton, ReactionType, ReplyParameters, + SentGuestMessage, SentWebAppMessage, StarAmount, StarTransactions, @@ -119,11 +121,14 @@ InlineQueryResult, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, InputMediaPhoto, InputMediaVideo, + InputPollMedia, InputSticker, InputStoryContent, LabeledPrice, + LivePhoto, Location, MessageEntity, PassportElementError, @@ -1119,6 +1124,28 @@ async def answer_web_app_query( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def answer_guest_query( + self, + guest_query_id: str, + result: "InlineQueryResult", + *, + 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 = None, + rate_limit_args: RLARGS | None = None, + ) -> SentGuestMessage: + return await super().answer_guest_query( + guest_query_id=guest_query_id, + result=result, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + async def approve_chat_join_request( self, chat_id: str | int, @@ -1844,6 +1871,7 @@ async def forward_messages( async def get_chat_administrators( self, chat_id: str | int, + return_bots: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1854,6 +1882,7 @@ async def get_chat_administrators( ) -> tuple[ChatMember, ...]: return await super().get_chat_administrators( chat_id=chat_id, + return_bots=return_bots, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -2689,7 +2718,7 @@ async def send_contact( async def send_checklist( self, business_connection_id: str, - chat_id: int, + chat_id: int | str, checklist: InputChecklist, disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, @@ -2727,7 +2756,7 @@ async def send_checklist( async def edit_message_checklist( self, business_connection_id: str, - chat_id: int, + chat_id: int | str, message_id: int, checklist: InputChecklist, reply_markup: "InlineKeyboardMarkup | None" = None, @@ -2858,7 +2887,7 @@ async def send_document( async def send_game( self, - chat_id: int, + chat_id: int | str, game_short_name: str, disable_notification: ODVInput[bool] = DEFAULT_NONE, reply_markup: "InlineKeyboardMarkup | None" = None, @@ -3044,7 +3073,7 @@ async def send_media_group( self, chat_id: int | str, media: Sequence[ - "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo | InputMediaLivePhoto" # noqa: E501 # pylint: disable=line-too-long ], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, @@ -3148,7 +3177,7 @@ async def send_message_draft( self, chat_id: int, draft_id: int, - text: str, + text: str | None = None, message_thread_id: int | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, entities: Sequence["MessageEntity"] | None = None, @@ -3262,9 +3291,13 @@ async def send_poll( hide_results_until_closes: bool | None = None, correct_option_ids: CorrectOptionIds | None = None, description: str | None = None, - description_parse_mode: str | None = None, + description_parse_mode: ODVInput[str] | None = None, description_entities: Sequence["MessageEntity"] | None = None, shuffle_options: bool | None = None, + members_only: bool | None = None, + country_codes: Sequence[str] | None = None, + explanation_media: "InputPollMedia | None" = None, + media: "InputPollMedia | None" = None, *, reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, @@ -3314,6 +3347,10 @@ async def send_poll( description_entities=description_entities, hide_results_until_closes=hide_results_until_closes, allow_adding_options=allow_adding_options, + members_only=members_only, + country_codes=country_codes, + explanation_media=explanation_media, + media=media, ) async def send_sticker( @@ -5032,6 +5069,74 @@ async def edit_user_star_subscription( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def get_managed_bot_access_settings( + self, + user_id: int, + *, + 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 = None, + rate_limit_args: RLARGS | None = None, + ) -> BotAccessSettings: + + return await super().get_managed_bot_access_settings( + user_id=user_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_managed_bot_access_settings( + self, + user_id: int, + is_access_restricted: bool, + added_user_ids: Sequence[int] | None = 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 = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + + return await super().set_managed_bot_access_settings( + user_id=user_id, + is_access_restricted=is_access_restricted, + added_user_ids=added_user_ids, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_user_personal_chat_messages( + self, + user_id: int, + limit: int, + *, + 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 = None, + rate_limit_args: RLARGS | None = None, + ) -> tuple[Message, ...]: + return await super().get_user_personal_chat_messages( + user_id=user_id, + limit=limit, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + async def send_paid_media( self, chat_id: str | int, @@ -5593,6 +5698,117 @@ async def save_prepared_keyboard_button( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def send_live_photo( + self, + chat_id: int | str, + live_photo: "FileInput | LivePhoto", + photo: "FileInput | PhotoSize", + business_connection_id: str | None = None, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + has_spoiler: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + allow_paid_broadcast: bool | None = None, + message_effect_id: str | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "ReplyMarkup | None" = None, + *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + filename: str | None = 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 = None, + rate_limit_args: RLARGS | None = None, + ) -> Message: + + return await super().send_live_photo( + chat_id=chat_id, + live_photo=live_photo, + photo=photo, + business_connection_id=business_connection_id, + message_thread_id=message_thread_id, + direct_messages_topic_id=direct_messages_topic_id, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + show_caption_above_media=show_caption_above_media, + has_spoiler=has_spoiler, + disable_notification=disable_notification, + protect_content=protect_content, + allow_paid_broadcast=allow_paid_broadcast, + message_effect_id=message_effect_id, + suggested_post_parameters=suggested_post_parameters, + reply_parameters=reply_parameters, + allow_sending_without_reply=allow_sending_without_reply, + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + filename=filename, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def delete_message_reaction( + self, + chat_id: int | str, + message_id: int, + user_id: int | None = None, + actor_chat_id: int | None = 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 = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().delete_message_reaction( + chat_id=chat_id, + message_id=message_id, + user_id=user_id, + actor_chat_id=actor_chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def delete_all_message_reactions( + self, + chat_id: int | str, + user_id: int | None = None, + actor_chat_id: int | None = 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 = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().delete_all_message_reactions( + chat_id=chat_id, + user_id=user_id, + actor_chat_id=actor_chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + # updated camelCase aliases getMe = get_me sendMessage = send_message @@ -5647,6 +5863,7 @@ async def save_prepared_keyboard_button( answerShippingQuery = answer_shipping_query answerPreCheckoutQuery = answer_pre_checkout_query answerWebAppQuery = answer_web_app_query + answerGuestQuery = answer_guest_query restrictChatMember = restrict_chat_member promoteChatMember = promote_chat_member setChatPermissions = set_chat_permissions @@ -5762,3 +5979,9 @@ async def save_prepared_keyboard_button( getManagedBotToken = get_managed_bot_token replaceManagedBotToken = replace_managed_bot_token savePreparedKeyboardButton = save_prepared_keyboard_button + sendLivePhoto = send_live_photo + getManagedBotAccessSettings = get_managed_bot_access_settings + setManagedBotAccessSettings = set_managed_bot_access_settings + getUserPersonalChatMessages = get_user_personal_chat_messages + deleteMessageReaction = delete_message_reaction + deleteAllMessageReactions = delete_all_message_reactions diff --git a/src/telegram/ext/filters.py b/src/telegram/ext/filters.py index d8fa167e7b0..701179da083 100644 --- a/src/telegram/ext/filters.py +++ b/src/telegram/ext/filters.py @@ -62,6 +62,7 @@ "IS_AUTOMATIC_FORWARD", "IS_FROM_OFFLINE", "IS_TOPIC_MESSAGE", + "LIVE_PHOTO", "LOCATION", "PAID_MEDIA", "PASSPORT_DATA", @@ -273,6 +274,10 @@ def check_update(self, update: Update) -> bool | FilterDataDict | None: :attr:`~telegram.Update.business_message` or :attr:`~telegram.Update.edited_business_message`. + .. versionchanged:: NEXT.VERSION + This filter now also returns :obj:`True` if the update contains + :attr:`~telegram.Update.guest_message`. + Args: update (:class:`telegram.Update`): The update to check. @@ -281,7 +286,8 @@ def check_update(self, update: Update) -> bool | FilterDataDict | None: :attr:`~telegram.Update.channel_post`, :attr:`~telegram.Update.message`, :attr:`~telegram.Update.edited_channel_post`, :attr:`~telegram.Update.edited_message`, :attr:`telegram.Update.business_message`, - :attr:`telegram.Update.edited_business_message`, or :obj:`False` otherwise. + :attr:`telegram.Update.edited_business_message`, + :attr:`telegram.Update.guest_message`, or :obj:`False` otherwise. """ return bool( # Only message updates should be handled. update.channel_post @@ -290,6 +296,7 @@ def check_update(self, update: Update) -> bool | FilterDataDict | None: or update.edited_message or update.business_message or update.edited_business_message + or update.guest_message ) @@ -1651,6 +1658,20 @@ def filter(self, message: Message) -> bool: ) +class _LivePhoto(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.live_photo) + + +LIVE_PHOTO = _LivePhoto(name="filters.LIVE_PHOTO") +"""Messages that contain :attr:`telegram.Message.live_photo`. + +.. versionadded:: NEXT.VERSION +""" + + class _Location(MessageFilter): __slots__ = () @@ -2894,6 +2915,18 @@ def filter(self, update: Update) -> bool: .. versionadded:: 21.1 """ + class _GuestMessage(UpdateFilter): + __slots__ = () + + def filter(self, update: Update) -> bool: + return update.guest_message is not None + + GUEST_MESSAGE = _GuestMessage(name="filters.UpdateType.GUEST_MESSAGE") + """Updates with :attr:`telegram.Update.guest_message`. + + .. versionadded:: NEXT.VERSION + """ + class User(_ChatUserBaseFilter): """Filters messages to allow only those which are from specified user ID(s) or diff --git a/tests/_files/conftest.py b/tests/_files/conftest.py index 5bff5e23728..9e8e5212e11 100644 --- a/tests/_files/conftest.py +++ b/tests/_files/conftest.py @@ -145,3 +145,19 @@ def video_sticker_file(): def video_sticker(bot, chat_id): with data_file("telegram_video_sticker.webm").open("rb") as f: return bot.send_sticker(chat_id, sticker=f, timeout=50).sticker + + +@pytest.fixture(scope="session") +async def real_live_photo(bot, chat_id): + with ( + data_file("telegram.jpg").open("rb") as photo, + data_file("telegram.mp4").open("rb") as video, + ): + return ( + await bot.send_live_photo( + chat_id, + live_photo=video, + photo=photo, + read_timeout=50, + ) + ).live_photo diff --git a/tests/_files/test_inputmedia.py b/tests/_files/test_inputmedia.py index d43a853ec7d..2cd9c9bc75a 100644 --- a/tests/_files/test_inputmedia.py +++ b/tests/_files/test_inputmedia.py @@ -29,15 +29,22 @@ InputMediaAnimation, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, + InputMediaLocation, InputMediaPhoto, + InputMediaSticker, + InputMediaVenue, InputMediaVideo, + InputPaidMediaLivePhoto, InputPaidMediaPhoto, InputPaidMediaVideo, + InputPollMedia, + InputPollOptionMedia, Message, MessageEntity, ReplyParameters, ) -from telegram.constants import InputMediaType, ParseMode +from telegram.constants import BaseInputMediaType, ParseMode from telegram.error import BadRequest from telegram.request import RequestData from telegram.warnings import PTBDeprecationWarning @@ -121,6 +128,37 @@ def input_media_document(class_thumb_file): ) +@pytest.fixture(scope="module") +def input_media_location(): + return InputMediaLocation( + latitude=InputMediaLocationTestBase.latitude, + longitude=InputMediaLocationTestBase.longitude, + horizontal_accuracy=InputMediaLocationTestBase.horizontal_accuracy, + ) + + +@pytest.fixture(scope="module") +def input_media_venue(): + return InputMediaVenue( + latitude=InputMediaVenueTestBase.latitude, + longitude=InputMediaVenueTestBase.longitude, + title=InputMediaVenueTestBase.title, + address=InputMediaVenueTestBase.address, + foursquare_id=InputMediaVenueTestBase.foursquare_id, + foursquare_type=InputMediaVenueTestBase.foursquare_type, + google_place_id=InputMediaVenueTestBase.google_place_id, + google_place_type=InputMediaVenueTestBase.google_place_type, + ) + + +@pytest.fixture(scope="module") +def input_media_sticker(): + return InputMediaSticker( + media=InputMediaStickerTestBase.media, + emoji=InputMediaStickerTestBase.emoji, + ) + + @pytest.fixture(scope="module") def input_paid_media_photo(): return InputPaidMediaPhoto( @@ -142,6 +180,27 @@ def input_paid_media_video(class_thumb_file): ) +@pytest.fixture(scope="module") +def input_media_live_photo(): + return InputMediaLivePhoto( + media=InputMediaLivePhotoTestBase.media, + photo=InputMediaLivePhotoTestBase.photo, + caption=InputMediaLivePhotoTestBase.caption, + parse_mode=InputMediaLivePhotoTestBase.parse_mode, + caption_entities=InputMediaLivePhotoTestBase.caption_entities, + show_caption_above_media=InputMediaLivePhotoTestBase.show_caption_above_media, + has_spoiler=InputMediaLivePhotoTestBase.has_spoiler, + ) + + +@pytest.fixture(scope="module") +def input_paid_media_live_photo(): + return InputPaidMediaLivePhoto( + media=InputMediaLivePhotoTestBase.media, + photo=InputMediaLivePhotoTestBase.photo, + ) + + class InputMediaVideoTestBase: type_ = "video" media = "NOTAREALFILEID" @@ -157,6 +216,21 @@ class InputMediaVideoTestBase: show_caption_above_media = True +class TestInputMediaWithoutRequest: + def test_type_enum_conversion(self): + assert type(InputMedia(media_type="video", media="media").type) is BaseInputMediaType + assert InputMedia(media_type="unknown", media="media").type == "unknown" + + def test_to_dict(self): + assert InputMedia( + media_type="video", + media="media", + ).to_dict() == { + "type": BaseInputMediaType.VIDEO, + "media": "media", + } + + class TestInputMediaVideoWithoutRequest(InputMediaVideoTestBase): def test_slot_behaviour(self, input_media_video): inst = input_media_video @@ -180,6 +254,10 @@ def test_expected_values(self, input_media_video): assert input_media_video.has_spoiler == self.has_spoiler assert input_media_video.show_caption_above_media == self.show_caption_above_media + assert isinstance(input_media_video, InputMedia) + assert isinstance(input_media_video, InputPollMedia) + assert isinstance(input_media_video, InputPollOptionMedia) + def test_caption_entities_always_tuple(self): input_media_video = InputMediaVideo(self.media) assert input_media_video.caption_entities == () @@ -253,25 +331,91 @@ def test_with_local_files(self): assert input_media_video.thumbnail == data_file("telegram.jpg").as_uri() assert input_media_video.cover == data_file("telegram.jpg").as_uri() - def test_type_enum_conversion(self): - # Since we have a lot of different test classes for all the input media types, we test this - # conversion only here. It is independent of the specific class - assert ( - type( - InputMedia( - media_type="animation", - media="media", - ).type - ) - is InputMediaType + def test_effective_filename(self, video_file): + inst = InputMediaVideo( + video_file, + "caption", + 24, + 24, + 10, + True, + "parse_mode", + [], + "pos_filename_depr", ) - assert ( - InputMedia( - media_type="unknown", - media="media", - ).type - == "unknown" + assert inst.media.filename == "pos_filename_depr" + + inst = InputMediaVideo( + video_file, + filename="kw_only_filename", + ) + assert inst.media.filename == "kw_only_filename" + + # Deprecated, but for completeness + inst = InputMediaVideo( + video_file, + filename_depr="kw_filename_depr", ) + assert inst.media.filename == "kw_filename_depr" + + def test_filename_depr_mutually_exclusive_filename(self, video_file): + with pytest.raises( + ValueError, match="`filename_depr` and `filename` are mutually exclusive" + ): + InputMediaVideo( + video_file, + "caption", + 24, + 24, + 10, + True, + "parse_mode", + [], + "pos_filename_depr", + filename="kw_filename", + ) + + with pytest.raises( + ValueError, match="`filename_depr` and `filename` are mutually exclusive" + ): + InputMediaVideo( + video_file, + filename_depr="filename_depr", + filename="kw_filename", + ) + + def test_positional_filename_deprecated(self, video_file): + with pytest.warns( + PTBDeprecationWarning, + match="Positional.*`filename`.*keyword.*`filename_depr`.*deprecated", + ) as record: + InputMediaVideo( + video_file, + "caption", + 24, + 24, + 10, + True, + "parse_mode", + [], + "pos_filename_depr", + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + + def test_keyword_filename_depr_deprecated(self, video_file): + with pytest.warns( + PTBDeprecationWarning, + match="Positional.*`filename`.*keyword.*`filename_depr`.*deprecated", + ) as record: + InputMediaVideo( + video_file, + filename_depr="filename_depr", + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" class InputMediaPhotoTestBase: @@ -300,6 +444,10 @@ def test_expected_values(self, input_media_photo): assert input_media_photo.has_spoiler == self.has_spoiler assert input_media_photo.show_caption_above_media == self.show_caption_above_media + assert isinstance(input_media_photo, InputMedia) + assert isinstance(input_media_photo, InputPollMedia) + assert isinstance(input_media_photo, InputPollOptionMedia) + def test_caption_entities_always_tuple(self): input_media_photo = InputMediaPhoto(self.media) assert input_media_photo.caption_entities == () @@ -337,6 +485,80 @@ def test_with_local_files(self): input_media_photo = InputMediaPhoto(data_file("telegram.mp4")) assert input_media_photo.media == data_file("telegram.mp4").as_uri() + def test_effective_filename(self, photo_file): + inst = InputMediaPhoto( + photo_file, + "caption", + "parse_mode", + [], + "pos_filename_depr", + ) + assert inst.media.filename == "pos_filename_depr" + + inst = InputMediaPhoto( + photo_file, + filename="kw_only_filename", + ) + assert inst.media.filename == "kw_only_filename" + + # Deprecated, but for completeness + inst = InputMediaPhoto( + photo_file, + filename_depr="kw_filename_depr", + ) + assert inst.media.filename == "kw_filename_depr" + + def test_filename_depr_mutually_exclusive_filename(self, photo_file): + with pytest.raises( + ValueError, match="`filename_depr` and `filename` are mutually exclusive" + ): + InputMediaPhoto( + photo_file, + "caption", + "parse_mode", + [], + "filename_depr", + filename="kw_filename", + ) + + with pytest.raises( + ValueError, match="`filename_depr` and `filename` are mutually exclusive" + ): + InputMediaPhoto( + photo_file, + filename_depr="filename_depr", + filename="kw_filename", + ) + + def test_positional_filename_deprecated(self, photo_file): + with pytest.warns( + PTBDeprecationWarning, + match="Positional.*`filename`.*keyword.*`filename_depr`.*deprecated", + ) as record: + InputMediaPhoto( + photo_file, + "caption", + "parse_mode", + [], + "filename_depr", + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + + def test_keyword_filename_depr_deprecated(self, photo_file): + with pytest.warns( + PTBDeprecationWarning, + match="Positional.*`filename`.*keyword.*`filename_depr`.*deprecated", + ) as record: + InputMediaPhoto( + photo_file, + filename_depr="filename_depr", + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + class InputMediaAnimationTestBase: type_ = "animation" @@ -369,6 +591,10 @@ def test_expected_values(self, input_media_animation): assert input_media_animation.show_caption_above_media == self.show_caption_above_media assert input_media_animation._duration == self.duration + assert isinstance(input_media_animation, InputMedia) + assert isinstance(input_media_animation, InputPollMedia) + assert isinstance(input_media_animation, InputPollOptionMedia) + def test_caption_entities_always_tuple(self): input_media_animation = InputMediaAnimation(self.media) assert input_media_animation.caption_entities == () @@ -433,6 +659,89 @@ def test_with_local_files(self): assert input_media_animation.media == data_file("telegram.mp4").as_uri() assert input_media_animation.thumbnail == data_file("telegram.jpg").as_uri() + def test_effective_filename(self, animation_file): + inst = InputMediaAnimation( + animation_file, + "caption", + "parse_mode", + 24, + 24, + 10, + [], + "pos_filename_depr", + ) + assert inst.media.filename == "pos_filename_depr" + + inst = InputMediaAnimation( + animation_file, + filename="kw_only_filename", + ) + assert inst.media.filename == "kw_only_filename" + + # Deprecated, but for completeness + inst = InputMediaAnimation( + animation_file, + filename_depr="kw_filename_depr", + ) + assert inst.media.filename == "kw_filename_depr" + + def test_filename_depr_mutually_exclusive_filename(self, animation_file): + with pytest.raises( + ValueError, match="`filename_depr` and `filename` are mutually exclusive" + ): + InputMediaAnimation( + animation_file, + "caption", + "parse_mode", + 24, + 24, + 10, + [], + "pos_filename_depr", + filename="kw_filename", + ) + + with pytest.raises( + ValueError, match="`filename_depr` and `filename` are mutually exclusive" + ): + InputMediaAnimation( + animation_file, + filename_depr="filename_depr", + filename="kw_filename", + ) + + def test_positional_filename_deprecated(self, animation_file): + with pytest.warns( + PTBDeprecationWarning, + match="Positional.*`filename`.*keyword.*`filename_depr`.*deprecated", + ) as record: + InputMediaAnimation( + animation_file, + "caption", + "parse_mode", + 24, + 24, + 10, + [], + "pos_filename_depr", + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + + def test_keyword_filename_depr_deprecated(self, animation_file): + with pytest.warns( + PTBDeprecationWarning, + match="Positional.*`filename`.*keyword.*`filename_depr`.*deprecated", + ) as record: + InputMediaAnimation( + animation_file, + filename_depr="filename_depr", + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + class InputMediaAudioTestBase: type_ = "audio" @@ -463,6 +772,10 @@ def test_expected_values(self, input_media_audio): assert input_media_audio.caption_entities == tuple(self.caption_entities) assert isinstance(input_media_audio.thumbnail, InputFile) + assert isinstance(input_media_audio, InputMedia) + assert isinstance(input_media_audio, InputPollMedia) + assert not isinstance(input_media_audio, InputPollOptionMedia) + def test_caption_entities_always_tuple(self): input_media_audio = InputMediaAudio(self.media) assert input_media_audio.caption_entities == () @@ -526,6 +839,89 @@ def test_with_local_files(self): assert input_media_audio.media == data_file("telegram.mp4").as_uri() assert input_media_audio.thumbnail == data_file("telegram.jpg").as_uri() + def test_effective_filename(self, audio_file): + inst = InputMediaAudio( + audio_file, + "caption", + "parse_mode", + 10, + "performer", + "title", + [], + "pos_filename_depr", + ) + assert inst.media.filename == "pos_filename_depr" + + inst = InputMediaAudio( + audio_file, + filename="kw_only_filename", + ) + assert inst.media.filename == "kw_only_filename" + + # Deprecated, but for completeness + inst = InputMediaAudio( + audio_file, + filename_depr="kw_filename_depr", + ) + assert inst.media.filename == "kw_filename_depr" + + def test_filename_depr_mutually_exclusive_filename(self, audio_file): + with pytest.raises( + ValueError, match="`filename_depr` and `filename` are mutually exclusive" + ): + InputMediaAudio( + audio_file, + "caption", + "parse_mode", + 10, + "performer", + "title", + [], + "pos_filename_depr", + filename="kw_filename", + ) + + with pytest.raises( + ValueError, match="`filename_depr` and `filename` are mutually exclusive" + ): + InputMediaAudio( + audio_file, + filename_depr="filename_depr", + filename="kw_filename", + ) + + def test_positional_filename_deprecated(self, audio_file): + with pytest.warns( + PTBDeprecationWarning, + match="Positional.*`filename`.*keyword.*`filename_depr`.*deprecated", + ) as record: + InputMediaAudio( + audio_file, + "caption", + "parse_mode", + 10, + "performer", + "title", + [], + "pos_filename_depr", + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + + def test_keyword_filename_depr_deprecated(self, audio_file): + with pytest.warns( + PTBDeprecationWarning, + match="Positional.*`filename`.*keyword.*`filename_depr`.*deprecated", + ) as record: + InputMediaAudio( + audio_file, + filename_depr="filename_depr", + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + class InputMediaDocumentTestBase: type_ = "document" @@ -536,6 +932,136 @@ class InputMediaDocumentTestBase: disable_content_type_detection = True +class InputMediaLocationTestBase: + type_ = "location" + latitude = 1.0 + longitude = 2.0 + horizontal_accuracy = 10.0 + + +class TestInputMediaLocationWithoutRequest(InputMediaLocationTestBase): + def test_slot_behaviour(self, input_media_location): + inst = input_media_location + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, input_media_location): + assert input_media_location.type == self.type_ + assert input_media_location.latitude == self.latitude + assert input_media_location.longitude == self.longitude + assert input_media_location.horizontal_accuracy == self.horizontal_accuracy + + assert isinstance(input_media_location, InputPollMedia) + assert isinstance(input_media_location, InputPollOptionMedia) + assert not isinstance(input_media_location, InputMedia) + + def test_to_dict(self, input_media_location): + input_media_location_dict = input_media_location.to_dict() + assert input_media_location_dict["type"] == input_media_location.type + assert input_media_location_dict["latitude"] == input_media_location.latitude + assert input_media_location_dict["longitude"] == input_media_location.longitude + assert ( + input_media_location_dict["horizontal_accuracy"] + == input_media_location.horizontal_accuracy + ) + + +class InputMediaVenueTestBase: + type_ = "venue" + latitude = 1.0 + longitude = 2.0 + title = "title" + address = "address" + foursquare_id = "foursquare_id" + foursquare_type = "food/icecream" + google_place_id = "google_place_id" + google_place_type = "restaurant" + + +class TestInputMediaVenueWithoutRequest(InputMediaVenueTestBase): + def test_slot_behaviour(self, input_media_venue): + inst = input_media_venue + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, input_media_venue): + assert input_media_venue.type == self.type_ + assert input_media_venue.latitude == self.latitude + assert input_media_venue.longitude == self.longitude + assert input_media_venue.title == self.title + assert input_media_venue.address == self.address + assert input_media_venue.foursquare_id == self.foursquare_id + assert input_media_venue.foursquare_type == self.foursquare_type + assert input_media_venue.google_place_id == self.google_place_id + assert input_media_venue.google_place_type == self.google_place_type + + assert isinstance(input_media_venue, InputPollMedia) + assert isinstance(input_media_venue, InputPollOptionMedia) + assert not isinstance(input_media_venue, InputMedia) + + def test_to_dict(self, input_media_venue): + input_media_venue_dict = input_media_venue.to_dict() + assert input_media_venue_dict["type"] == input_media_venue.type + assert input_media_venue_dict["latitude"] == input_media_venue.latitude + assert input_media_venue_dict["longitude"] == input_media_venue.longitude + assert input_media_venue_dict["title"] == input_media_venue.title + assert input_media_venue_dict["address"] == input_media_venue.address + assert input_media_venue_dict["foursquare_id"] == input_media_venue.foursquare_id + assert input_media_venue_dict["foursquare_type"] == input_media_venue.foursquare_type + assert input_media_venue_dict["google_place_id"] == input_media_venue.google_place_id + assert input_media_venue_dict["google_place_type"] == input_media_venue.google_place_type + + +class InputMediaStickerTestBase: + type_ = "sticker" + media = "NOTAREALFILEID" + emoji = "💪" + + +class TestInputMediaStickerWithoutRequest(InputMediaStickerTestBase): + def test_slot_behaviour(self, input_media_sticker): + inst = input_media_sticker + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, input_media_sticker): + assert input_media_sticker.type == self.type_ + assert input_media_sticker.media == self.media + assert input_media_sticker.emoji == self.emoji + + assert isinstance(input_media_sticker, InputPollOptionMedia) + assert not isinstance(input_media_sticker, InputPollMedia) + assert not isinstance(input_media_sticker, InputMedia) + + def test_to_dict(self, input_media_sticker): + input_media_sticker_dict = input_media_sticker.to_dict() + assert input_media_sticker_dict["type"] == input_media_sticker.type + assert input_media_sticker_dict["media"] == input_media_sticker.media + assert input_media_sticker_dict["emoji"] == input_media_sticker.emoji + + def test_with_sticker(self, sticker): + input_media_sticker = InputMediaSticker(sticker, emoji=self.emoji) + assert input_media_sticker.type == self.type_ + assert input_media_sticker.media == sticker.file_id + assert input_media_sticker.emoji == self.emoji + + def test_with_sticker_file(self, sticker_file): + input_media_sticker = InputMediaSticker(sticker_file, emoji=self.emoji) + assert input_media_sticker.type == self.type_ + assert isinstance(input_media_sticker.media, InputFile) + assert input_media_sticker.emoji == self.emoji + + def test_with_local_files(self): + input_media_sticker = InputMediaSticker( + data_file("telegram_sticker.png"), emoji=self.emoji + ) + assert input_media_sticker.media == data_file("telegram_sticker.png").as_uri() + assert input_media_sticker.emoji == self.emoji + + class TestInputMediaDocumentWithoutRequest(InputMediaDocumentTestBase): def test_slot_behaviour(self, input_media_document): inst = input_media_document @@ -555,6 +1081,10 @@ def test_expected_values(self, input_media_document): ) assert isinstance(input_media_document.thumbnail, InputFile) + assert isinstance(input_media_document, InputMedia) + assert isinstance(input_media_document, InputPollMedia) + assert not isinstance(input_media_document, InputPollOptionMedia) + def test_caption_entities_always_tuple(self): input_media_document = InputMediaDocument(self.media) assert input_media_document.caption_entities == () @@ -594,6 +1124,83 @@ def test_with_local_files(self): assert input_media_document.media == data_file("telegram.mp4").as_uri() assert input_media_document.thumbnail == data_file("telegram.jpg").as_uri() + def test_effective_filename(self, document_file): + inst = InputMediaDocument( + document_file, + "caption", + "parse_mode", + True, + [], + "pos_filename_depr", + ) + assert inst.media.filename == "pos_filename_depr" + + inst = InputMediaDocument( + document_file, + filename="kw_only_filename", + ) + assert inst.media.filename == "kw_only_filename" + + # Deprecated, but for completeness + inst = InputMediaDocument( + document_file, + filename_depr="kw_filename_depr", + ) + assert inst.media.filename == "kw_filename_depr" + + def test_filename_depr_mutually_exclusive_filename(self, document_file): + with pytest.raises( + ValueError, match="`filename_depr` and `filename` are mutually exclusive" + ): + InputMediaDocument( + document_file, + "caption", + "parse_mode", + True, + [], + "pos_filename_depr", + filename="kw_filename", + ) + + with pytest.raises( + ValueError, match="`filename_depr` and `filename` are mutually exclusive" + ): + InputMediaDocument( + document_file, + filename_depr="filename_depr", + filename="kw_filename", + ) + + def test_positional_filename_deprecated(self, document_file): + with pytest.warns( + PTBDeprecationWarning, + match="Positional.*`filename`.*keyword.*`filename_depr`.*deprecated", + ) as record: + InputMediaDocument( + document_file, + "caption", + "parse_mode", + True, + [], + "pos_filename_depr", + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + + def test_keyword_filename_depr_deprecated(self, document_file): + with pytest.warns( + PTBDeprecationWarning, + match="Positional.*`filename`.*keyword.*`filename_depr`.*deprecated", + ) as record: + InputMediaDocument( + document_file, + filename_depr="filename_depr", + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + class TestInputPaidMediaPhotoWithoutRequest(InputMediaPhotoTestBase): def test_slot_behaviour(self, input_paid_media_photo): @@ -612,13 +1219,13 @@ def test_to_dict(self, input_paid_media_photo): assert input_paid_media_photo_dict["media"] == input_paid_media_photo.media def test_with_photo(self, photo): - # fixture found in test_photo + # fixture found in conftest.py input_paid_media_photo = InputPaidMediaPhoto(photo) assert input_paid_media_photo.type == self.type_ assert input_paid_media_photo.media == photo.file_id def test_with_photo_file(self, photo_file): - # fixture found in test_photo + # fixture found in conftest.py input_paid_media_photo = InputPaidMediaPhoto(photo_file) assert input_paid_media_photo.type == self.type_ assert isinstance(input_paid_media_photo.media, InputFile) @@ -628,6 +1235,76 @@ def test_with_local_files(self): assert input_paid_media_photo.media == data_file("telegram.jpg").as_uri() +class InputMediaLivePhotoTestBase: + type_ = "live_photo" + media = "NOTAREALFILEID" + photo = "NOTAREALFILEID" + caption = "My Caption" + parse_mode = "Markdown" + caption_entities = [MessageEntity(MessageEntity.BOLD, 0, 2)] + show_caption_above_media = True + has_spoiler = True + + +class TestInputMediaLivePhotoWithoutRequest(InputMediaLivePhotoTestBase): + def test_slot_behaviour(self, input_media_live_photo): + inst = input_media_live_photo + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, input_media_live_photo): + assert input_media_live_photo.type == self.type_ + assert input_media_live_photo.media == self.media + assert input_media_live_photo.photo == self.photo + assert input_media_live_photo.caption == self.caption + assert input_media_live_photo.parse_mode == self.parse_mode + assert input_media_live_photo.caption_entities == tuple(self.caption_entities) + assert input_media_live_photo.show_caption_above_media == self.show_caption_above_media + assert input_media_live_photo.has_spoiler == self.has_spoiler + + def test_caption_entities_always_tuple(self): + input_media_live_photo = InputMediaLivePhoto(self.media, self.photo) + assert input_media_live_photo.caption_entities == () + + def test_to_dict(self, input_media_live_photo): + input_media_live_photo_dict = input_media_live_photo.to_dict() + assert input_media_live_photo_dict["type"] == input_media_live_photo.type + assert input_media_live_photo_dict["media"] == input_media_live_photo.media + assert input_media_live_photo_dict["photo"] == input_media_live_photo.photo + assert input_media_live_photo_dict["caption"] == input_media_live_photo.caption + assert input_media_live_photo_dict["parse_mode"] == input_media_live_photo.parse_mode + assert input_media_live_photo_dict["caption_entities"] == [ + ce.to_dict() for ce in input_media_live_photo.caption_entities + ] + assert ( + input_media_live_photo_dict["show_caption_above_media"] + == input_media_live_photo.show_caption_above_media + ) + assert input_media_live_photo_dict["has_spoiler"] == input_media_live_photo.has_spoiler + + def test_with_photo_and_video(self, video, photo): + # fixtures found in conftest.py + input_media_live_photo = InputMediaLivePhoto(video, photo) + assert input_media_live_photo.type == self.type_ + assert input_media_live_photo.media == video.file_id + assert input_media_live_photo.photo == photo.file_id + + def test_with_photo_and_video_files(self, video_file, photo_file): + # fixture found in conftest.py + input_media_live_photo = InputMediaLivePhoto(video_file, photo_file) + assert input_media_live_photo.type == self.type_ + assert isinstance(input_media_live_photo.media, InputFile) + assert isinstance(input_media_live_photo.photo, InputFile) + + def test_with_local_files(self): + input_media_live_photo = InputMediaLivePhoto( + media=data_file("telegram.mp4"), photo=data_file("telegram.jpg") + ) + assert input_media_live_photo.media == data_file("telegram.mp4").as_uri() + assert input_media_live_photo.photo == data_file("telegram.jpg").as_uri() + + class TestInputPaidMediaVideoWithoutRequest(InputMediaVideoTestBase): def test_slot_behaviour(self, input_paid_media_video): inst = input_paid_media_video @@ -711,6 +1388,46 @@ def test_with_local_files(self): assert input_paid_media_video.cover == data_file("telegram.jpg").as_uri() +class TestInputPaidMediaLivePhotoWithoutRequest(InputMediaLivePhotoTestBase): + def test_slot_behaviour(self, input_paid_media_live_photo): + inst = input_paid_media_live_photo + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, input_paid_media_live_photo): + assert input_paid_media_live_photo.type == self.type_ + assert input_paid_media_live_photo.media == self.media + assert input_paid_media_live_photo.photo == self.photo + + def test_to_dict(self, input_paid_media_live_photo): + input_paid_media_live_photo_dict = input_paid_media_live_photo.to_dict() + assert input_paid_media_live_photo_dict["type"] == input_paid_media_live_photo.type + assert input_paid_media_live_photo_dict["media"] == input_paid_media_live_photo.media + assert input_paid_media_live_photo_dict["photo"] == input_paid_media_live_photo.photo + + def test_with_photo(self, video, photo): + # fixtures found in conftest.py + input_paid_media_live_photo = InputPaidMediaLivePhoto(video, photo) + assert input_paid_media_live_photo.type == self.type_ + assert input_paid_media_live_photo.media == video.file_id + assert input_paid_media_live_photo.photo == photo.file_id + + def test_with_photo_file(self, photo_file): + # fixture found in conftest.py + input_paid_media_live_photo = InputPaidMediaLivePhoto(photo_file, photo_file) + assert input_paid_media_live_photo.type == self.type_ + assert isinstance(input_paid_media_live_photo.media, InputFile) + assert isinstance(input_paid_media_live_photo.photo, InputFile) + + def test_with_local_files(self): + input_paid_media_live_photo = InputPaidMediaLivePhoto( + media=data_file("telegram.mp4"), photo=data_file("telegram.jpg") + ) + assert input_paid_media_live_photo.media == data_file("telegram.mp4").as_uri() + assert input_paid_media_live_photo.photo == data_file("telegram.jpg").as_uri() + + @pytest.fixture(scope="module") def media_group(photo, thumb): return [ @@ -1155,7 +1872,9 @@ async def test_send_media_group_default_parse_mode( @pytest.mark.parametrize( "default_bot", [{"parse_mode": ParseMode.HTML}], indirect=True, ids=["HTML-Bot"] ) - @pytest.mark.parametrize("media_type", ["animation", "document", "audio", "photo", "video"]) + @pytest.mark.parametrize( + "media_type", ["animation", "document", "audio", "live_photo", "photo", "video"] + ) async def test_edit_message_media_default_parse_mode( self, chat_id, @@ -1194,6 +1913,8 @@ def build_media(parse_mode, med_type): return InputMediaPhoto(photo, **kwargs) if med_type == "video": return InputMediaVideo(video, **kwargs) + if med_type == "live_photo": + return InputMediaLivePhoto(video, photo, **kwargs) return None message = await default_bot.send_photo(chat_id, photo) diff --git a/tests/_files/test_livephoto.py b/tests/_files/test_livephoto.py new file mode 100644 index 00000000000..0dba659033e --- /dev/null +++ b/tests/_files/test_livephoto.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 tests a Telegram LivePhoto.""" + +import asyncio +import datetime as dtm +import os +from pathlib import Path + +import pytest + +from telegram import ( + InputFile, + LivePhoto, + MessageEntity, + PhotoSize, + ReplyParameters, + Voice, +) +from telegram.constants import ParseMode +from telegram.error import BadRequest, TelegramError +from telegram.helpers import escape_markdown +from telegram.request import RequestData +from tests.auxil.build_messages import make_message +from tests.auxil.files import data_file +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def live_photo(): + return LivePhoto( + file_id=LivePhotoTestBase.file_id, + file_unique_id=LivePhotoTestBase.file_unique_id, + width=LivePhotoTestBase.width, + height=LivePhotoTestBase.height, + duration=LivePhotoTestBase.duration, + photo=LivePhotoTestBase.photo, + mime_type=LivePhotoTestBase.mime_type, + file_size=LivePhotoTestBase.file_size, + ) + + +class LivePhotoTestBase: + caption = "LivePhotoTest - *Caption*" + width = 360 + height = 640 + duration = dtm.timedelta(seconds=5) + file_size = 326534 + mime_type = "video/mp4" + photo = (PhotoSize("file_id", "unique_id", 640, 360, file_size=0),) + file_id = "5a3128a4d2a04750b5b58397f3b5e812" + file_unique_id = "adc3145fd2e84d95b64d68eaa22aa33e" + + +class TestLivePhotoWithoutRequest(LivePhotoTestBase): + def test_slot_behaviour(self, live_photo): + for attr in live_photo.__slots__: + assert getattr(live_photo, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(live_photo)) == len(set(mro_slots(live_photo))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "file_id": self.file_id, + "file_unique_id": self.file_unique_id, + "width": self.width, + "height": self.height, + "duration": int(self.duration.total_seconds()), + "mime_type": self.mime_type, + "file_size": self.file_size, + "photo": [photo_size.to_dict() for photo_size in self.photo], + } + json_live_photo = LivePhoto.de_json(json_dict, offline_bot) + assert json_live_photo.api_kwargs == {} + + assert json_live_photo.file_id == self.file_id + assert json_live_photo.file_unique_id == self.file_unique_id + assert json_live_photo.width == self.width + assert json_live_photo.height == self.height + assert json_live_photo.duration == self.duration + assert json_live_photo.mime_type == self.mime_type + assert json_live_photo.file_size == self.file_size + assert json_live_photo.photo == self.photo + + def test_to_dict(self, live_photo): + live_photo_dict = live_photo.to_dict() + + assert isinstance(live_photo_dict, dict) + assert live_photo_dict["file_id"] == live_photo.file_id + assert live_photo_dict["file_unique_id"] == live_photo.file_unique_id + assert live_photo_dict["width"] == live_photo.width + assert live_photo_dict["height"] == live_photo.height + assert live_photo_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(live_photo_dict["duration"], int) + assert live_photo_dict["mime_type"] == live_photo.mime_type + assert live_photo_dict["file_size"] == live_photo.file_size + assert live_photo_dict["photo"] == [p.to_dict() for p in self.photo] + + def test_equality(self, live_photo): + a = LivePhoto( + live_photo.file_id, live_photo.file_unique_id, self.width, self.height, self.duration + ) + b = LivePhoto("", live_photo.file_unique_id, self.width, self.height, self.duration) + c = LivePhoto(live_photo.file_id, live_photo.file_unique_id, 0, 0, 0) + d = LivePhoto("", "", self.width, self.height, self.duration) + e = Voice(live_photo.file_id, live_photo.file_unique_id, self.duration) + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a == c + assert hash(a) == hash(c) + + assert a != d + assert hash(a) != hash(d) + + assert a != e + assert hash(a) != hash(e) + + async def test_send_with_live_photo(self, monkeypatch, offline_bot, chat_id, live_photo): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + data = request_data.parameters + return ( + data["live_photo"] == live_photo.file_id + and data["photo"] == live_photo.photo[0].file_id + ) + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + assert await offline_bot.send_live_photo( + chat_id=chat_id, + live_photo=live_photo, + photo=live_photo.photo[0], + ) + + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_live_photo_default_quote_parse_mode( + self, default_bot, chat_id, live_photo, custom, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["reply_parameters"].get("quote_parse_mode") == ( + custom or default_bot.defaults.quote_parse_mode + ) + return make_message("dummy reply").to_dict() + + kwargs = {"message_id": "1"} + if custom is not None: + kwargs["quote_parse_mode"] = custom + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_live_photo( + chat_id=chat_id, + live_photo=live_photo, + photo=live_photo.photo[0], + reply_parameters=ReplyParameters(**kwargs), + ) + + @pytest.mark.parametrize("local_mode", [True, False]) + async def test_send_live_photo( + self, dummy_message_dict, monkeypatch, offline_bot, chat_id, local_mode + ): + try: + offline_bot._local_mode = local_mode + # For just test that the correct paths are passed as we have no local Bot API set up + test_flag = False + photo = data_file("telegram.jpg") + expected_photo = photo.as_uri() + + live_photo = data_file("telegram.mp4") + expected_live_photo = live_photo.as_uri() + + async def make_assertion(_, data, *args, **kwargs): + nonlocal test_flag + if local_mode: + test_flag = ( + data.get("live_photo") == expected_live_photo + and data.get("photo") == expected_photo + ) + else: + test_flag = isinstance(data.get("live_photo"), InputFile) and isinstance( + data.get("photo"), InputFile + ) + return dummy_message_dict + + monkeypatch.setattr(offline_bot, "_post", make_assertion) + await offline_bot.send_live_photo(chat_id, live_photo=live_photo, photo=photo) + assert test_flag + finally: + offline_bot._local_mode = False + + +class TestLivePhotoWithRequest(LivePhotoTestBase): + async def test_error_send_empty_file(self, bot, chat_id): + with Path(os.devnull).open("rb") as f, pytest.raises(TelegramError): + await bot.send_live_photo(chat_id=chat_id, live_photo=f, photo=f) + + async def test_error_send_empty_file_id(self, bot, chat_id): + with pytest.raises(TelegramError): + await bot.send_live_photo(chat_id=chat_id, live_photo="", photo="") + + async def test_get_and_download(self, bot, real_live_photo, tmp_file): + new_file = await bot.get_file(real_live_photo.file_id) + + assert new_file.file_size == real_live_photo.file_size + assert new_file.file_unique_id == real_live_photo.file_unique_id + assert new_file.file_path.startswith("https://") + + await new_file.download_to_drive(tmp_file) + + assert tmp_file.is_file() + + async def test_send_resend(self, bot, chat_id, real_live_photo, photo_file): + message = await bot.send_live_photo( + chat_id=chat_id, live_photo=real_live_photo.file_id, photo=photo_file + ) + assert message.live_photo == real_live_photo + + async def test_send_all_args(self, bot, chat_id, video_file, live_photo, photo_file): + message = await bot.send_live_photo( + chat_id, + live_photo=video_file, + photo=photo_file, + caption=self.caption, + disable_notification=False, + protect_content=True, + filename="telegram_custom.png", + parse_mode="Markdown", + ) + + assert isinstance(message.live_photo, LivePhoto) + assert isinstance(message.live_photo.file_id, str) + assert message.live_photo.file_id + assert isinstance(message.live_photo.file_unique_id, str) + assert message.live_photo.file_unique_id + assert message.live_photo.photo + assert isinstance(message.live_photo.photo[0], PhotoSize) + assert message.live_photo.mime_type == live_photo.mime_type + assert message.live_photo.file_size == live_photo.file_size + assert message.caption == self.caption.replace("*", "") + assert message.has_protected_content + + @pytest.mark.parametrize("default_bot", [{"protect_content": True}], indirect=True) + async def test_send_live_photo_default_protect_content( + self, + chat_id, + default_bot, + real_live_photo, + ): + tasks = asyncio.gather( + default_bot.send_live_photo( + chat_id, photo=real_live_photo.photo[0], live_photo=real_live_photo + ), + default_bot.send_live_photo( + chat_id, + photo=real_live_photo.photo[0], + live_photo=real_live_photo, + protect_content=False, + ), + ) + protected, unprotected = await tasks + assert protected.has_protected_content + assert not unprotected.has_protected_content + + async def test_send_live_photo_caption_entities(self, bot, chat_id, video_file, photo_file): + test_string = "Italic Bold Code" + entities = [ + MessageEntity(MessageEntity.ITALIC, 0, 6), + MessageEntity(MessageEntity.ITALIC, 7, 4), + MessageEntity(MessageEntity.ITALIC, 12, 4), + ] + message = await bot.send_live_photo( + chat_id, + photo=photo_file, + live_photo=video_file, + caption=test_string, + caption_entities=entities, + ) + + assert message.caption == test_string + assert message.caption_entities == tuple(entities) + + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) + async def test_send_live_photo_default_parse_mode_1( + self, default_bot, chat_id, video_file, photo_file + ): + test_string = "Italic Bold Code" + test_markdown_string = "_Italic_ *Bold* `Code`" + + message = await default_bot.send_live_photo( + chat_id, photo=photo_file, live_photo=video_file, caption=test_markdown_string + ) + assert message.caption_markdown == test_markdown_string + assert message.caption == test_string + + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) + async def test_send_live_photo_default_parse_mode_2( + self, default_bot, chat_id, video_file, photo_file + ): + test_markdown_string = "_Italic_ *Bold* `Code`" + + message = await default_bot.send_live_photo( + chat_id, + photo=photo_file, + live_photo=video_file, + caption=test_markdown_string, + parse_mode=None, + ) + assert message.caption == test_markdown_string + assert message.caption_markdown == escape_markdown(test_markdown_string) + + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) + async def test_send_live_photo_default_parse_mode_3( + self, default_bot, chat_id, video_file, photo_file + ): + test_markdown_string = "_Italic_ *Bold* `Code`" + + message = await default_bot.send_live_photo( + chat_id, + photo=photo_file, + live_photo=video_file, + caption=test_markdown_string, + parse_mode="HTML", + ) + assert message.caption == test_markdown_string + assert message.caption_markdown == escape_markdown(test_markdown_string) + + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"allow_sending_without_reply": True}, None), + ({"allow_sending_without_reply": False}, None), + ({"allow_sending_without_reply": False}, True), + ], + indirect=["default_bot"], + ) + async def test_send_live_photo_default_allow_sending_without_reply( + self, default_bot, chat_id, video_file, photo_file, custom + ): + reply_to_message = await default_bot.send_message(chat_id, "test") + await reply_to_message.delete() + if custom is not None: + message = await default_bot.send_live_photo( + chat_id, + photo=photo_file, + live_photo=video_file, + allow_sending_without_reply=custom, + reply_to_message_id=reply_to_message.message_id, + ) + assert message.reply_to_message is None + elif default_bot.defaults.allow_sending_without_reply: + message = await default_bot.send_live_photo( + chat_id, + photo=photo_file, + live_photo=video_file, + reply_to_message_id=reply_to_message.message_id, + ) + assert message.reply_to_message is None + else: + with pytest.raises(BadRequest, match="Message to be replied not found"): + await default_bot.send_live_photo( + chat_id, + photo=photo_file, + live_photo=video_file, + reply_to_message_id=reply_to_message.message_id, + ) diff --git a/tests/auxil/bot_method_checks.py b/tests/auxil/bot_method_checks.py index 6714e65655a..8624b45a739 100644 --- a/tests/auxil/bot_method_checks.py +++ b/tests/auxil/bot_method_checks.py @@ -45,7 +45,7 @@ ) from telegram._utils.datetime import to_timestamp from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue -from telegram.constants import InputMediaType +from telegram.constants import BaseInputMediaType from telegram.ext import Defaults, ExtBot from telegram.request import RequestData from tests.auxil.dummy_objects import get_dummy_object_json_dict @@ -512,7 +512,7 @@ def check_input_media(m: dict): media = data.pop("media", None) paid_media = media and data.pop("star_count", None) if media and not paid_media: - if isinstance(media, dict) and isinstance(media.get("type", None), InputMediaType): + if isinstance(media, dict) and isinstance(media.get("type", None), BaseInputMediaType): check_input_media(media) else: for m in media: diff --git a/tests/auxil/dummy_objects.py b/tests/auxil/dummy_objects.py index c0ff0ce0cf5..41c938c70d0 100644 --- a/tests/auxil/dummy_objects.py +++ b/tests/auxil/dummy_objects.py @@ -4,6 +4,7 @@ from telegram import ( AcceptedGiftTypes, + BotAccessSettings, BotCommand, BotDescription, BotName, @@ -30,6 +31,7 @@ PollOption, PreparedInlineMessage, PreparedKeyboardButton, + SentGuestMessage, SentWebAppMessage, StarAmount, StarTransaction, @@ -63,6 +65,7 @@ _PREPARED_DUMMY_OBJECTS: dict[str, object] = { "bool": True, + "BotAccessSettings": BotAccessSettings(is_access_restricted=True, added_users=[_DUMMY_USER]), "BotCommand": BotCommand(command="dummy_command", description="dummy_description"), "BotDescription": BotDescription(description="dummy_description"), "BotName": BotName(name="dummy_name"), @@ -127,15 +130,18 @@ "Poll": Poll( id="dummy_id", question="dummy_question", - options=[PollOption(text="dummy_text", voter_count=1)], + options=[PollOption(text="dummy_text", voter_count=1, persistent_id="persistent_id")], is_closed=False, is_anonymous=False, total_voter_count=1, type="dummy_type", allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ), "PreparedKeyboardButton": PreparedKeyboardButton(id=1234), "PreparedInlineMessage": PreparedInlineMessage(id="dummy_id", expiration_date=_DUMMY_DATE), + "SentGuestMessage": SentGuestMessage(inline_message_id="dummy_inline_message_id"), "SentWebAppMessage": SentWebAppMessage(inline_message_id="dummy_inline_message_id"), "StarAmount": StarAmount(amount=100, nanostar_amount=356), "StarTransactions": StarTransactions( diff --git a/tests/ext/test_filters.py b/tests/ext/test_filters.py index e68fb0dbc09..9271649d635 100644 --- a/tests/ext/test_filters.py +++ b/tests/ext/test_filters.py @@ -945,6 +945,11 @@ def test_filters_location(self, update): update.message.location = "test" assert filters.LOCATION.check_update(update) + def test_filters_live_photo(self, update): + assert not filters.LIVE_PHOTO.check_update(update) + update.message.live_photo = "test" + assert filters.LIVE_PHOTO.check_update(update) + def test_filters_venue(self, update): assert not filters.VENUE.check_update(update) update.message.venue = "test" @@ -2471,6 +2476,7 @@ def test_update_type_message(self, update): assert not filters.UpdateType.BUSINESS_MESSAGES.check_update(update) assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.GUEST_MESSAGE.check_update(update) def test_update_type_edited_message(self, update): update.edited_message, update.message = update.message, update.edited_message @@ -2484,6 +2490,7 @@ def test_update_type_edited_message(self, update): assert not filters.UpdateType.BUSINESS_MESSAGES.check_update(update) assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.GUEST_MESSAGE.check_update(update) def test_update_type_channel_post(self, update): update.channel_post, update.message = update.message, update.edited_message @@ -2497,6 +2504,7 @@ def test_update_type_channel_post(self, update): assert not filters.UpdateType.BUSINESS_MESSAGES.check_update(update) assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.GUEST_MESSAGE.check_update(update) def test_update_type_edited_channel_post(self, update): update.edited_channel_post, update.message = update.message, update.edited_message @@ -2510,6 +2518,7 @@ def test_update_type_edited_channel_post(self, update): assert not filters.UpdateType.BUSINESS_MESSAGES.check_update(update) assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.GUEST_MESSAGE.check_update(update) def test_update_type_business_message(self, update): update.business_message, update.message = update.message, update.edited_message @@ -2523,6 +2532,7 @@ def test_update_type_business_message(self, update): assert filters.UpdateType.BUSINESS_MESSAGES.check_update(update) assert filters.UpdateType.BUSINESS_MESSAGE.check_update(update) assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.GUEST_MESSAGE.check_update(update) def test_update_type_edited_business_message(self, update): update.edited_business_message, update.message = update.message, update.edited_message @@ -2536,6 +2546,21 @@ def test_update_type_edited_business_message(self, update): assert filters.UpdateType.BUSINESS_MESSAGES.check_update(update) assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) assert filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.GUEST_MESSAGE.check_update(update) + + def test_update_type_guest_message(self, update): + update.guest_message, update.message = update.message, update.edited_message + assert not filters.UpdateType.MESSAGE.check_update(update) + assert not filters.UpdateType.EDITED_MESSAGE.check_update(update) + assert not filters.UpdateType.MESSAGES.check_update(update) + assert not filters.UpdateType.CHANNEL_POST.check_update(update) + assert not filters.UpdateType.EDITED_CHANNEL_POST.check_update(update) + assert not filters.UpdateType.CHANNEL_POSTS.check_update(update) + assert not filters.UpdateType.EDITED.check_update(update) + assert not filters.UpdateType.BUSINESS_MESSAGES.check_update(update) + assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert filters.UpdateType.GUEST_MESSAGE.check_update(update) def test_merged_short_circuit_and(self, update, base_class): update.message.text = "/test" diff --git a/tests/ext/test_pollanswerhandler.py b/tests/ext/test_pollanswerhandler.py index 8900f466486..b22e9f0e0d9 100644 --- a/tests/ext/test_pollanswerhandler.py +++ b/tests/ext/test_pollanswerhandler.py @@ -69,7 +69,16 @@ def false_update(request): @pytest.fixture def poll_answer(bot): - return Update(0, poll_answer=PollAnswer(1, [0, 1], User(2, "test user", False), Chat(1, ""))) + return Update( + 0, + poll_answer=PollAnswer( + poll_id=1, + option_ids=[0, 1], + option_persistent_ids=["0", "1"], + user=User(2, "test user", False), + voter_chat=Chat(1, ""), + ), + ) class TestPollAnswerHandler: diff --git a/tests/ext/test_pollhandler.py b/tests/ext/test_pollhandler.py index cb2864822fe..c85da77eec3 100644 --- a/tests/ext/test_pollhandler.py +++ b/tests/ext/test_pollhandler.py @@ -73,14 +73,19 @@ def poll(bot): return Update( 0, poll=Poll( - 1, - "question", - [PollOption("1", 0), PollOption("2", 0)], - 0, - False, - False, - Poll.REGULAR, - True, + id=1, + question="question", + options=[ + PollOption(text="1", voter_count=0, persistent_id="1"), + PollOption(text="2", voter_count=0, persistent_id="2"), + ], + total_voter_count=0, + is_closed=False, + is_anonymous=False, + type=Poll.REGULAR, + allows_revoting=True, + members_only=True, + allows_multiple_answers=True, ), ) diff --git a/tests/test_bot.py b/tests/test_bot.py index 2fd77550ecb..5f9fb22a3eb 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -33,6 +33,7 @@ from telegram import ( Bot, + BotAccessSettings, BotCommand, BotCommandScopeChat, BotDescription, @@ -53,6 +54,7 @@ InlineQueryResultVoice, InputFile, InputMediaDocument, + InputMediaLocation, InputMediaPhoto, InputMessageContent, InputPollOption, @@ -76,6 +78,7 @@ ReactionTypeCustomEmoji, ReactionTypeEmoji, ReplyParameters, + SentGuestMessage, SentWebAppMessage, ShippingOption, StarTransaction, @@ -860,6 +863,150 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): == copied_result.input_message_content.parse_mode ) + async def test_answer_guest_query(self, offline_bot, raw_bot, monkeypatch): + params = False + + # For now just test that our internals pass the correct data + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + nonlocal params + params = request_data.parameters == { + "guest_query_id": "12345", + "result": { + "title": "title", + "input_message_content": { + "message_text": "text", + }, + "type": InlineQueryResultType.ARTICLE, + "id": "1", + }, + } + return SentGuestMessage("321").to_dict() + + result = InlineQueryResultArticle("1", "title", InputTextMessageContent("text")) + copied_result = copy.copy(result) + + ext_bot = offline_bot + for bot_type in (ext_bot, raw_bot): + monkeypatch.setattr(bot_type.request, "post", make_assertion) + guest_msg = await bot_type.answer_guest_query("12345", result) + assert params, "something went wrong with passing arguments to the request" + assert isinstance(guest_msg, SentGuestMessage) + assert guest_msg.inline_message_id == "321" + + # make sure that the results were not edited in-place + assert result == copied_result + assert ( + result.input_message_content.parse_mode + == copied_result.input_message_content.parse_mode + ) + + @pytest.mark.parametrize( + "default_bot", + [{"parse_mode": "Markdown", "link_preview_options": LinkPreviewOptions(is_disabled=True)}], + indirect=True, + ) + @pytest.mark.parametrize( + ("ilq_result", "expected_params"), + [ + ( + InlineQueryResultArticle("1", "title", InputTextMessageContent("text")), + { + "guest_query_id": "12345", + "result": { + "title": "title", + "input_message_content": { + "message_text": "text", + "parse_mode": "Markdown", + "link_preview_options": { + "is_disabled": True, + }, + }, + "type": InlineQueryResultType.ARTICLE, + "id": "1", + }, + }, + ), + ( + InlineQueryResultArticle( + "1", + "title", + InputTextMessageContent( + "text", parse_mode="HTML", disable_web_page_preview=False + ), + ), + { + "guest_query_id": "12345", + "result": { + "title": "title", + "input_message_content": { + "message_text": "text", + "parse_mode": "HTML", + "link_preview_options": { + "is_disabled": False, + }, + }, + "type": InlineQueryResultType.ARTICLE, + "id": "1", + }, + }, + ), + ( + InlineQueryResultArticle( + "1", + "title", + InputTextMessageContent( + "text", parse_mode=None, disable_web_page_preview="False" + ), + ), + { + "guest_query_id": "12345", + "result": { + "title": "title", + "input_message_content": { + "message_text": "text", + "link_preview_options": { + "is_disabled": "False", + }, + }, + "type": InlineQueryResultType.ARTICLE, + "id": "1", + }, + }, + ), + ], + ) + async def test_answer_guest_query_defaults( + self, default_bot, ilq_result, expected_params, monkeypatch + ): + offline_bot = default_bot + params = False + + # For now just test that our internals pass the correct data + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + nonlocal params + params = request_data.parameters == expected_params + return SentGuestMessage("321").to_dict() + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + # We test different result types more thoroughly for answer_inline_query, so we just + # use the one type here + copied_result = copy.copy(ilq_result) + + guest_msg = await offline_bot.answer_guest_query("12345", ilq_result) + assert params, "something went wrong with passing arguments to the request" + assert isinstance(guest_msg, SentGuestMessage) + assert guest_msg.inline_message_id == "321" + + # make sure that the results were not edited in-place + assert ilq_result == copied_result + assert ( + ilq_result.input_message_content.parse_mode + == copied_result.input_message_content.parse_mode + ) + # TODO: Needs improvement. We need incoming inline query to test answer. @pytest.mark.parametrize("button_type", ["start", "web_app"]) @pytest.mark.parametrize("cache_time", [74, dtm.timedelta(seconds=74)]) @@ -1794,12 +1941,16 @@ async def post(*args, **kwargs): poll=Poll( "42", "question", - options=[PollOption("option", 0)], + options=[ + PollOption(text="option", voter_count=0, persistent_id="persistent_id") + ], total_voter_count=0, is_closed=False, is_anonymous=True, type=Poll.REGULAR, allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ), ) return [update.to_dict()] @@ -2446,12 +2597,14 @@ async def test_business_connection_id_argument( Poll( id="42", question="question", - options=[PollOption("option", 0)], + options=[PollOption(text="option", voter_count=0, persistent_id="persistent_id")], total_voter_count=5, is_closed=True, is_anonymous=True, type="regular", allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ).to_dict() ) await return_values.put(True) @@ -2920,6 +3073,62 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): ) assert isinstance(inst, PreparedKeyboardButton) + async def test_get_managed_bot_access_settings(self, offline_bot, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("user_id") == 1234 + return BotAccessSettings( + is_access_restricted=True, + added_users=[User(1, "first", False)], + ).to_dict() + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + settings = await offline_bot.get_managed_bot_access_settings(1234) + assert isinstance(settings, BotAccessSettings) + + async def test_set_managed_bot_access_settings(self, offline_bot, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("user_id") == 1234 + assert request_data.parameters.get("is_access_restricted") is True + assert request_data.parameters.get("added_user_ids") == [1, 2, 3] + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + await offline_bot.set_managed_bot_access_settings( + 1234, + is_access_restricted=True, + added_user_ids=[1, 2, 3], + ) + + async def test_get_user_personal_chat_messages(self, offline_bot, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("user_id") == 1234 + assert request_data.parameters.get("limit") == 1 + return [make_message("dummy reply").to_dict()] + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + msgs = await offline_bot.get_user_personal_chat_messages(1234, limit=1) + assert isinstance(msgs, tuple) + assert all(isinstance(msg, Message) for msg in msgs) + + # Bots cannot delete their own reaction from my testing, so we aren't making a real request + async def test_delete_message_reaction(self, offline_bot, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("chat_id") == 1234 + assert request_data.parameters.get("message_id") == 12 + assert request_data.parameters.get("user_id") == 3432 + assert request_data.parameters.get("actor_chat_id") == 1232 + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + await offline_bot.delete_message_reaction(1234, 12, 3432, 1232) + + async def test_delete_all_message_reactions(self, offline_bot, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("chat_id") == 1234 + assert request_data.parameters.get("user_id") == 3432 + assert request_data.parameters.get("actor_chat_id") == 1232 + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + await offline_bot.delete_all_message_reactions(1234, 3432, 1232) + class TestBotWithRequest: """ @@ -3275,6 +3484,34 @@ async def test_send_poll_explanation_entities(self, bot, chat_id): assert message.poll.explanation == test_string assert message.poll.explanation_entities == tuple(entities) + async def test_send_poll_media_parameters(self, bot, channel_id): + with ( + data_file("telegram.jpg").open("rb") as photo_file, + data_file("text_file.txt").open("rb") as document_file, + ): + i_photo = InputMediaPhoto(InputFile(photo_file, attach=True)) + i_document = InputMediaDocument(InputFile(document_file, attach=True)) + i_location = InputMediaLocation(latitude=0, longitude=0) + + message = await bot.send_poll( + channel_id, + question="question", + options=[ + InputPollOption("option1", media=i_location), + InputPollOption("option2"), + ], + type=Poll.QUIZ, + correct_option_ids=[0], + media=i_photo, + explanation_media=i_document, + is_closed=True, + read_timeout=60, + ) + + assert message.poll.media.photo + assert message.poll.explanation_media.document + assert message.poll.options[0].media.location + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) async def test_send_poll_default_parse_mode(self, default_bot, super_group_id): explanation = "Italic Bold Code" @@ -3705,11 +3942,15 @@ async def test_get_chat(self, bot, super_group_id): assert cfi.id == int(super_group_id) async def test_get_chat_administrators(self, bot, channel_id): - admins = await bot.get_chat_administrators(channel_id) + admins = await bot.get_chat_administrators(channel_id, return_bots=True) assert isinstance(admins, tuple) + bots_found = 0 for a in admins: assert a.status in ("administrator", "creator") + if a.user.is_bot: + bots_found += 1 + assert bots_found > 1 # will be False if return_bots=False async def test_get_chat_member_count(self, bot, channel_id): count = await bot.get_chat_member_count(channel_id) @@ -4924,6 +5165,12 @@ async def test_my_profile_photo(self, bot): bot_profile_photos = await bot.get_user_profile_photos(bot.id) assert bot_profile_photos.total_count == 1 + async def test_get_user_personal_chat_messages(self, bot): + # id is of the Test User + messages = await bot.get_user_personal_chat_messages(user_id=675666224, limit=2) + assert isinstance(messages, tuple) + assert len(messages) == 2 + async def test_initialize_tracks_requests_and_bot_separately(self, offline_bot, monkeypatch): """Test that requests and bot user are initialized separately and only once.""" request_init_count = 0 diff --git a/tests/test_botaccesssettings.py b/tests/test_botaccesssettings.py new file mode 100644 index 00000000000..56cb8d05f50 --- /dev/null +++ b/tests/test_botaccesssettings.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 Access Settings.""" + +import pytest + +from telegram import BotAccessSettings, Dice +from telegram._user import User +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def bot_access_settings(): + return BotAccessSettings( + is_access_restricted=BotAccessSettingsTestBase.is_access_restricted, + added_users=BotAccessSettingsTestBase.added_users, + ) + + +class BotAccessSettingsTestBase: + is_access_restricted = True + added_users = [User(id=123, first_name="John", is_bot=False)] + + +class TestBotAccessSettingsWithoutRequest(BotAccessSettingsTestBase): + def test_slot_behaviour(self, bot_access_settings): + inst = bot_access_settings + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "is_access_restricted": self.is_access_restricted, + "added_users": [user.to_dict() for user in self.added_users], + } + bot_access_settings = BotAccessSettings.de_json(json_dict, offline_bot) + assert bot_access_settings.api_kwargs == {} + + assert bot_access_settings.is_access_restricted == self.is_access_restricted + assert bot_access_settings.added_users == tuple(self.added_users) + + def test_to_dict(self, bot_access_settings): + bot_access_settings_dict = bot_access_settings.to_dict() + + assert isinstance(bot_access_settings_dict, dict) + assert ( + bot_access_settings_dict["is_access_restricted"] + == bot_access_settings.is_access_restricted + ) + assert isinstance(bot_access_settings_dict["added_users"], list) + assert bot_access_settings_dict["added_users"][0] == self.added_users[0].to_dict() + + def test_equality(self): + a = BotAccessSettings(is_access_restricted=True, added_users=self.added_users) + b = BotAccessSettings(is_access_restricted=True, added_users=self.added_users) + c = BotAccessSettings(is_access_restricted=False, added_users=self.added_users) + d = BotAccessSettings(is_access_restricted=True, added_users=None) + e = Dice(emoji="🎲", value=4) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + assert a != e + assert hash(a) != hash(e) diff --git a/tests/test_chat.py b/tests/test_chat.py index 8d1d2db23ce..99208b5844c 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -563,6 +563,21 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(chat.get_bot(), "send_photo", make_assertion) assert await chat.send_photo(photo="test_photo") + async def test_instance_method_send_live_photo(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["live_photo"] == "test_live_photo" + and kwargs["photo"] == "test_photo" + ) + + assert check_shortcut_signature(Chat.send_live_photo, Bot.send_live_photo, ["chat_id"], []) + assert await check_shortcut_call(chat.send_live_photo, chat.get_bot(), "send_live_photo") + assert await check_defaults_handling(chat.send_live_photo, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "send_live_photo", make_assertion) + assert await chat.send_live_photo(live_photo="test_live_photo", photo="test_photo") + async def test_instance_method_send_contact(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == chat.id and kwargs["phone_number"] == "test_contact" @@ -1537,6 +1552,68 @@ async def make_assertion(*_, **kwargs): active_period=3600, ) + async def test_instance_method_delete_reaction(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["message_id"] == 321 + and kwargs["user_id"] == 123 + and kwargs["actor_chat_id"] == 222 + ) + + assert check_shortcut_signature( + Chat.delete_reaction, + Bot.delete_message_reaction, + [ + "chat_id", + ], + additional_kwargs=[], + ) + assert await check_shortcut_call( + chat.delete_reaction, + chat.get_bot(), + "delete_message_reaction", + shortcut_kwargs=["chat_id"], + ) + assert await check_defaults_handling(chat.delete_reaction, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "delete_message_reaction", make_assertion) + assert await chat.delete_reaction( + user_id=123, + message_id=321, + actor_chat_id=222, + ) + + async def test_instance_method_delete_all_reactions(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["user_id"] == 123 + and kwargs["actor_chat_id"] == 222 + ) + + assert check_shortcut_signature( + Chat.delete_all_reactions, + Bot.delete_all_message_reactions, + [ + "chat_id", + ], + additional_kwargs=[], + ) + assert await check_shortcut_call( + chat.delete_all_reactions, + chat.get_bot(), + "delete_all_message_reactions", + shortcut_kwargs=["chat_id"], + ) + assert await check_defaults_handling(chat.delete_all_reactions, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "delete_all_message_reactions", make_assertion) + assert await chat.delete_all_reactions( + user_id=123, + actor_chat_id=222, + ) + async def test_instance_method_get_gifts(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == chat.id diff --git a/tests/test_chatmember.py b/tests/test_chatmember.py index 7bc4a2979f6..ead273bdbd2 100644 --- a/tests/test_chatmember.py +++ b/tests/test_chatmember.py @@ -61,6 +61,7 @@ class ChatMemberTestBase: can_pin_messages = True can_post_stories = True can_edit_stories = True + can_react_to_messages = True can_delete_stories = True can_manage_topics = True until_date = dtm.datetime.now(UTC).replace(microsecond=0) @@ -576,6 +577,7 @@ def chat_member_restricted(): is_member=TestChatMemberRestrictedWithoutRequest.is_member, until_date=TestChatMemberRestrictedWithoutRequest.until_date, can_edit_tag=TestChatMemberRestrictedWithoutRequest.can_edit_tag, + can_react_to_messages=TestChatMemberRestrictedWithoutRequest.can_react_to_messages, tag=TestChatMemberRestrictedWithoutRequest.tag, ) @@ -609,6 +611,7 @@ def test_de_json(self, offline_bot): "is_member": self.is_member, "until_date": to_timestamp(self.until_date), "can_edit_tag": self.can_edit_tag, + "can_react_to_messages": self.can_react_to_messages, "tag": self.tag, # legacy argument "can_send_media_messages": False, @@ -636,6 +639,7 @@ def test_de_json(self, offline_bot): assert chat_member.is_member == self.is_member assert chat_member.until_date == self.until_date assert chat_member.can_edit_tag == self.can_edit_tag + assert chat_member.can_react_to_messages == self.can_react_to_messages assert chat_member.tag == self.tag def test_de_json_localization(self, tz_bot, offline_bot, raw_bot, chat_member_restricted): @@ -676,9 +680,22 @@ def test_to_dict(self, chat_member_restricted): "is_member": chat_member_restricted.is_member, "until_date": to_timestamp(chat_member_restricted.until_date), "can_edit_tag": chat_member_restricted.can_edit_tag, + "can_react_to_messages": chat_member_restricted.can_react_to_messages, "tag": chat_member_restricted.tag, } + def test_can_react_to_messages_raises(self, chat_member_restricted): + with pytest.raises( + TypeError, match="`can_react_to_messages` is required and cannot be None" + ): + ChatMemberRestricted( + *[ + getattr(chat_member_restricted, k) + for k in chat_member_restricted.__slots__ + if k != "can_react_to_messages" + ] + ) + def test_equality(self, chat_member_restricted): a = chat_member_restricted b = deepcopy(chat_member_restricted) @@ -701,6 +718,7 @@ def test_equality(self, chat_member_restricted): False, False, False, + False, "tag", ) d = Dice(5, "test") diff --git a/tests/test_chatpermissions.py b/tests/test_chatpermissions.py index 0baee3f89d7..43aeabcf537 100644 --- a/tests/test_chatpermissions.py +++ b/tests/test_chatpermissions.py @@ -41,6 +41,7 @@ def chat_permissions(): can_send_video_notes=True, can_send_voice_notes=True, can_edit_tag=True, + can_react_to_messages=True, ) @@ -60,6 +61,7 @@ class ChatPermissionsTestBase: can_send_video_notes = False can_send_voice_notes = None can_edit_tag = None + can_react_to_messages = True class TestChatPermissionsWithoutRequest(ChatPermissionsTestBase): @@ -86,6 +88,7 @@ def test_de_json(self, offline_bot): "can_send_video_notes": self.can_send_video_notes, "can_send_voice_notes": self.can_send_voice_notes, "can_edit_tag": self.can_edit_tag, + "can_react_to_messages": self.can_react_to_messages, } permissions = ChatPermissions.de_json(json_dict, offline_bot) assert permissions.api_kwargs == {"can_send_media_messages": "can_send_media_messages"} @@ -105,6 +108,7 @@ def test_de_json(self, offline_bot): assert permissions.can_send_video_notes == self.can_send_video_notes assert permissions.can_send_voice_notes == self.can_send_voice_notes assert permissions.can_edit_tag == self.can_edit_tag + assert permissions.can_react_to_messages == self.can_react_to_messages def test_to_dict(self, chat_permissions): permissions_dict = chat_permissions.to_dict() @@ -130,6 +134,7 @@ def test_to_dict(self, chat_permissions): assert permissions_dict["can_send_video_notes"] == chat_permissions.can_send_video_notes assert permissions_dict["can_send_voice_notes"] == chat_permissions.can_send_voice_notes assert permissions_dict["can_edit_tag"] == chat_permissions.can_edit_tag + assert permissions_dict["can_react_to_messages"] == chat_permissions.can_react_to_messages def test_equality(self): a = ChatPermissions( @@ -159,6 +164,7 @@ def test_equality(self): can_send_video_notes=True, can_send_voice_notes=True, can_edit_tag=True, + can_react_to_messages=True, ) f = ChatPermissions( can_send_messages=True, @@ -171,6 +177,7 @@ def test_equality(self): can_send_video_notes=True, can_send_voice_notes=True, can_edit_tag=True, + can_react_to_messages=True, ) assert a == b diff --git a/tests/test_constants.py b/tests/test_constants.py index c5e7ff000bf..0a72417cfab 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -204,6 +204,9 @@ def is_type_attribute(name: str) -> bool: "external_reply", "via_bot", "is_from_offline", + "guest_bot_caller_chat", + "guest_bot_caller_user", + "guest_query_id", "show_caption_above_media", "paid_star_count", "is_paid_post", diff --git a/tests/test_message.py b/tests/test_message.py index 958c59b3109..1003e3b161e 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -50,11 +50,14 @@ GiveawayCompleted, GiveawayCreated, GiveawayWinners, + InlineQueryResultArticle, InputChecklist, InputChecklistTask, InputPaidMediaPhoto, + InputTextMessageContent, Invoice, LinkPreviewOptions, + LivePhoto, Location, ManagedBotCreated, Message, @@ -131,6 +134,7 @@ def message(bot): chat=copy(MessageTestBase.chat), from_user=copy(MessageTestBase.from_user), business_connection_id="123456789", + guest_query_id="706654132", ) message.set_bot(bot) message._unfreeze() @@ -213,13 +217,18 @@ def message(bot): "poll": Poll( id="abc", question="What is this?", - options=[PollOption(text="a", voter_count=1), PollOption(text="b", voter_count=2)], + options=[ + PollOption(text="a", voter_count=1, persistent_id="persistent_id_a"), + PollOption(text="b", voter_count=2, persistent_id="persistent_id_b"), + ], is_closed=False, total_voter_count=0, is_anonymous=False, type=Poll.REGULAR, allows_multiple_answers=True, explanation_entities=[], + allows_revoting=True, + members_only=True, ) }, { @@ -443,6 +452,10 @@ def message(bot): {"poll_option_deleted": PollOptionDeleted(option_persistent_id="abc", option_text="this")}, {"reply_to_poll_option_id": "3123"}, {"managed_bot_created": ManagedBotCreated(bot=User(6, "ManagedBot", True))}, + {"guest_bot_caller_user": User(10, "hm", False)}, + {"guest_bot_caller_chat": Chat(14, "om")}, + {"guest_query_id": "This is a guest_query_id"}, + {"live_photo": LivePhoto("file_id", "file_unique_id", 12, 12, 5)}, ], ids=[ "reply", @@ -541,6 +554,10 @@ def message(bot): "poll_option_deleted", "reply_to_poll_option_id", "managed_bot_created", + "guest_bot_caller_user", + "guest_bot_caller_chat", + "guest_query_id", + "live_photo", ], ) def message_params(bot, request): @@ -1486,6 +1503,7 @@ def test_effective_attachment(self, message_params): "document", "game", "invoice", + "live_photo", "location", "paid_media", "passport_data", @@ -2009,6 +2027,54 @@ async def make_assertion(*_, **kwargs): message, message.reply_photo, "send_photo", ["test_photo"], monkeypatch ) + async def test_reply_live_photo(self, monkeypatch, message): + async def make_assertion(*_, **kwargs): + id_ = kwargs["chat_id"] == message.chat_id + live_photo = kwargs["live_photo"] == "test_live_photo" + photo = kwargs["photo"] == "test_photo" + return id_ and live_photo and photo + + assert check_shortcut_signature( + Message.reply_live_photo, + Bot.send_live_photo, + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_live_photo, + message.get_bot(), + "send_live_photo", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_live_photo, message.get_bot(), no_default_kwargs={"message_thread_id"} + ) + + monkeypatch.setattr(message.get_bot(), "send_live_photo", make_assertion) + assert await message.reply_live_photo(live_photo="test_live_photo", photo="test_photo") + await self.check_quote_parsing( + message, + message.reply_live_photo, + "send_live_photo", + ["test_live_photo", "test_photo"], + monkeypatch, + ) + + await self.check_thread_id_parsing( + message, + message.reply_live_photo, + "send_live_photo", + ["test_live_photo", "test_photo"], + monkeypatch, + ) + async def test_reply_audio(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id @@ -3419,3 +3485,54 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(message.get_bot(), "decline_suggested_post", make_assertion) assert await message.decline_suggested_post(comment="some comment") + + async def test_delete_reaction(self, monkeypatch, message): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == message.chat_id + and kwargs["message_id"] == message.message_id + and kwargs["user_id"] == 23 + and kwargs["actor_chat_id"] == 12 + ) + + assert check_shortcut_signature( + Message.delete_reaction, + Bot.delete_message_reaction, + ["chat_id", "message_id"], + [], + ) + assert await check_shortcut_call( + message.delete_reaction, + message.get_bot(), + "delete_message_reaction", + shortcut_kwargs=["chat_id", "message_id"], + ) + assert await check_defaults_handling(message.delete_reaction, message.get_bot()) + + monkeypatch.setattr(message.get_bot(), "delete_message_reaction", make_assertion) + assert await message.delete_reaction(user_id=23, actor_chat_id=12) + + async def test_answer_guest_query(self, monkeypatch, message): + iqra = InlineQueryResultArticle( + id="iqra_id", title="title", input_message_content=InputTextMessageContent("content") + ) + + async def make_assertion(*_, **kwargs): + return kwargs["guest_query_id"] == message.guest_query_id and kwargs["result"] == iqra + + assert check_shortcut_signature( + Message.answer_guest_query, + Bot.answer_guest_query, + ["guest_query_id"], + [], + ) + assert await check_shortcut_call( + message.answer_guest_query, + message.get_bot(), + "answer_guest_query", + shortcut_kwargs=["guest_query_id"], + ) + assert await check_defaults_handling(message.answer_guest_query, message.get_bot()) + + monkeypatch.setattr(message.get_bot(), "answer_guest_query", make_assertion) + assert await message.answer_guest_query(result=iqra) diff --git a/tests/test_official/exceptions.py b/tests/test_official/exceptions.py index f62ef853990..d063909db9d 100644 --- a/tests/test_official/exceptions.py +++ b/tests/test_official/exceptions.py @@ -20,7 +20,18 @@ from collections.abc import Sequence -from telegram import Animation, Audio, Document, Gift, PhotoSize, Sticker, Video, VideoNote, Voice +from telegram import ( + Animation, + Audio, + Document, + Gift, + LivePhoto, + PhotoSize, + Sticker, + Video, + VideoNote, + Voice, +) from tests.test_official.helpers import _get_params_base IGNORED_OBJECTS = ("ResponseParameters",) @@ -41,6 +52,7 @@ class ParamTypeCheckingExceptions: ADDITIONAL_TYPES = { r"send_\w*": { "photo$": PhotoSize, + "live_photo": LivePhoto, "video$": Video, "video_note": VideoNote, "audio": Audio, @@ -67,6 +79,7 @@ class ParamTypeCheckingExceptions: ("keyboard", True): "KeyboardButton", # + sequence[sequence[str]] ("reaction", False): "ReactionType", # + str ("options", False): "InputPollOption", # + str + ("correct_option_ids", False): "Sequence[typing.Literal[", } # Special cases for other parameters that accept more types than the official API, and are @@ -91,6 +104,7 @@ class ParamTypeCheckingExceptions: }, "Input(Paid)?Media.*": { "media": str, # actual: Union[str, InputMedia*, FileInput] + "photo": str, # actual: Union[str, FileInput] # see also https://github.com/tdlib/telegram-bot-api/issues/707 "thumbnail": str, # actual: Union[str, FileInput] "cover": str, # actual: Union[str, FileInput] @@ -138,7 +152,7 @@ class ParamTypeCheckingExceptions: "send_venue": {"venue"}, "answer_inline_query": {"current_offset"}, "send_media_group": {"caption", "parse_mode", "caption_entities"}, - "send_(animation|audio|document|photo|video(_note)?|voice)": {"filename"}, + "send_(animation|audio|document|photo|video(_note)?|voice|live_photo)": {"filename"}, "InlineQueryResult": {"id", "type"}, # attributes common to all subclasses "ChatMember": {"user", "status"}, # attributes common to all subclasses "BotCommandScope": {"type"}, # attributes common to all subclasses @@ -146,8 +160,13 @@ class ParamTypeCheckingExceptions: "PassportFile": {"credentials"}, "EncryptedPassportElement": {"credentials"}, "PassportElementError": {"source", "type", "message"}, + "InputPoll(Option)?Media": {"media_type"}, "InputMedia": {"caption", "caption_entities", "media", "media_type", "parse_mode"}, - "InputMedia(Animation|Audio|Document|Photo|Video|VideoNote|Voice)": {"filename"}, + "InputMedia(Animation|Audio|Document|Photo|Sticker|Video|VideoNote|Voice)": { + "filename", + # tags: deprecated NEXT.VERSION + "filename_depr", + }, "InputFile": {"attach", "filename", "obj", "read_file_handle"}, "MaybeInaccessibleMessage": {"date", "message_id", "chat"}, # attributes common to all subcls "ChatBoostSource": {"source"}, # attributes common to all subclasses @@ -164,6 +183,12 @@ class ParamTypeCheckingExceptions: "InputStoryContent": {"type"}, # attributes common to all subclasses "StoryAreaType": {"type"}, # attributes common to all subclasses "InputProfilePhoto": {"type"}, # attributes common to all subclasses + "InputPollOptionMedia": {"args", "kwargs"}, # UnionType's __init__ signature + "InputPollMedia": {"args", "kwargs"}, # UnionType's __init__ signature + # backwards compatibility for api 10.0 changes + # tags: deprecated NEXT.VERSION, bot api 10.0 + "Poll": {"correct_option_id"}, + "send_poll": {"correct_option_id"}, } @@ -222,7 +247,9 @@ def ignored_param_requirements(object_name: str) -> set[str]: BACKWARDS_COMPAT_KWARGS: dict[str, set[str]] = { "PollOption": {"persistent_id"}, "PollAnswer": {"option_persistent_ids"}, - "Poll": {"allows_revoting"}, + "Poll": {"allows_revoting", "members_only"}, + "ChatMemberRestricted": {"can_react_to_messages"}, + "send_poll": {"correct_option_id"}, } diff --git a/tests/test_paidmedia.py b/tests/test_paidmedia.py index 536fcc1f2ad..b6efb1b01a8 100644 --- a/tests/test_paidmedia.py +++ b/tests/test_paidmedia.py @@ -24,8 +24,10 @@ from telegram import ( Dice, + LivePhoto, PaidMedia, PaidMediaInfo, + PaidMediaLivePhoto, PaidMediaPhoto, PaidMediaPreview, PaidMediaPurchased, @@ -64,6 +66,14 @@ class PaidMediaTestBase: file_unique_id="file_unique_id", ), ) + live_photo = LivePhoto( + file_id="live_photo_file_id", + file_unique_id="live_photo_file_unique_id", + width=640, + height=480, + duration=dtm.timedelta(seconds=60), + photo=photo, + ) class TestPaidMediaWithoutRequest(PaidMediaTestBase): @@ -89,6 +99,7 @@ def test_de_json(self, offline_bot): ("photo", PaidMediaPhoto), ("video", PaidMediaVideo), ("preview", PaidMediaPreview), + ("live_photo", PaidMediaLivePhoto), ], ) def test_de_json_subclass(self, offline_bot, pm_type, subclass): @@ -99,6 +110,7 @@ def test_de_json_subclass(self, offline_bot, pm_type, subclass): "width": self.width, "height": self.height, "duration": int(self.duration.total_seconds()), + "live_photo": self.live_photo.to_dict(), } pm = PaidMedia.de_json(json_dict, offline_bot) @@ -226,6 +238,56 @@ def test_equality(self, paid_media_video): assert hash(a) != hash(d) +@pytest.fixture +def paid_media_live_photo(): + return PaidMediaLivePhoto( + live_photo=TestPaidMediaLivePhotoWithoutRequest.live_photo, + ) + + +class TestPaidMediaLivePhotoWithoutRequest(PaidMediaTestBase): + type = PaidMediaType.LIVE_PHOTO + + def test_slot_behaviour(self, paid_media_live_photo): + inst = paid_media_live_photo + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "live_photo": self.live_photo.to_dict(), + } + pmlp = PaidMediaLivePhoto.de_json(json_dict, offline_bot) + assert pmlp.live_photo == self.live_photo + assert pmlp.api_kwargs == {} + + def test_to_dict(self, paid_media_live_photo): + assert paid_media_live_photo.to_dict() == { + "type": self.type, + "live_photo": paid_media_live_photo.live_photo.to_dict(), + } + + def test_equality(self, paid_media_live_photo): + a = paid_media_live_photo + b = PaidMediaLivePhoto( + live_photo=deepcopy(self.live_photo), + ) + c = PaidMediaLivePhoto( + live_photo=LivePhoto("test", "test_unique", 640, 480, 60), + ) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + @pytest.fixture def paid_media_preview(): return PaidMediaPreview( diff --git a/tests/test_poll.py b/tests/test_poll.py index 0f1998ed17d..10d39a2fe98 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -20,16 +20,28 @@ import pytest from telegram import ( + Animation, + Audio, Chat, + Document, + InputMediaPhoto, InputPollOption, + LivePhoto, + Location, MaybeInaccessibleMessage, MessageEntity, + PhotoSize, Poll, PollAnswer, + PollMedia, PollOption, + PollOptionAdded, + PollOptionDeleted, + Sticker, User, + Venue, + Video, ) -from telegram._poll import PollOptionAdded, PollOptionDeleted from telegram._utils.datetime import UTC, to_timestamp from telegram.constants import PollType from telegram.warnings import PTBDeprecationWarning @@ -42,6 +54,7 @@ def input_poll_option(): text=InputPollOptionTestBase.text, text_parse_mode=InputPollOptionTestBase.text_parse_mode, text_entities=InputPollOptionTestBase.text_entities, + media=InputPollOptionTestBase.media, ) out._unfreeze() return out @@ -54,6 +67,7 @@ class InputPollOptionTestBase: MessageEntity(0, 4, MessageEntity.BOLD), MessageEntity(5, 7, MessageEntity.ITALIC), ] + media = InputMediaPhoto("media") class TestInputPollOptionWithoutRequest(InputPollOptionTestBase): @@ -64,6 +78,7 @@ def test_slot_behaviour(self, input_poll_option): "duplicate slot" ) + # tags: deprecated NEXT.VERSION def test_de_json(self): json_dict = { "text": self.text, @@ -77,6 +92,16 @@ def test_de_json(self): assert input_poll_option.text_parse_mode == self.text_parse_mode assert input_poll_option.text_entities == tuple(self.text_entities) + def test_de_json_deprecated(self, recwarn): + InputPollOption.de_json({"text": self.text}, None) + + assert len(recwarn) == 1 + assert "`InputPollOption.de_json` is deprecated" in str(recwarn[0].message) + assert "The `media` field will not be included for deserialization" in str( + recwarn[0].message + ) + assert recwarn[0].category is PTBDeprecationWarning + def test_to_dict(self, input_poll_option): input_poll_option_dict = input_poll_option.to_dict() @@ -86,6 +111,7 @@ def test_to_dict(self, input_poll_option): assert input_poll_option_dict["text_entities"] == [ e.to_dict() for e in input_poll_option.text_entities ] + assert input_poll_option_dict["media"] == input_poll_option.media.to_dict() # Test that the default-value parameter is handled correctly input_poll_option = InputPollOption("text") @@ -97,7 +123,18 @@ def test_equality(self): b = InputPollOption("text", self.text_parse_mode) c = InputPollOption("text", text_entities=self.text_entities) d = InputPollOption("different_text") - e = Poll(123, "question", ["O1", "O2"], 1, False, True, Poll.REGULAR, True) + e = Poll( + 123, + "question", + ["O1", "O2"], + 1, + False, + True, + Poll.REGULAR, + True, + allows_revoting=True, + members_only=True, + ) assert a == b assert hash(a) == hash(b) @@ -112,6 +149,112 @@ def test_equality(self): assert hash(a) != hash(e) +@pytest.fixture(scope="module") +def poll_media(): + return PollMedia( + animation=PollMediaTestBase.animation, + audio=PollMediaTestBase.audio, + document=PollMediaTestBase.document, + live_photo=PollMediaTestBase.live_photo, + location=PollMediaTestBase.location, + photo=PollMediaTestBase.photo, + sticker=PollMediaTestBase.sticker, + venue=PollMediaTestBase.venue, + video=PollMediaTestBase.video, + ) + + +class PollMediaTestBase: + animation = Animation("blah", "unique_id", 320, 180, 1) + audio = Audio(file_id="file_id", file_unique_id="file_unique_id", duration=30) + document = Document("file_id", "file_unique_id", "file_name", 42) + location = Location(123, 456) + photo = (PhotoSize("file_id", "file_unique_id", 1, 1),) + sticker = Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular") + venue = Venue(location=Location(123, 456), title="title", address="address") + video = Video( + file_id="video_file_id", + width=640, + height=480, + file_unique_id="file_unique_id", + duration=dtm.timedelta(seconds=60), + ) + live_photo = LivePhoto( + file_id="video_file_id", + file_unique_id="file_unique_id", + width=640, + height=480, + duration=dtm.timedelta(seconds=60), + mime_type="video/mp4", + file_size=326534, + ) + + +class TestPollMediaWithoutRequest(PollMediaTestBase): + def test_slot_behaviour(self, poll_media): + for attr in poll_media.__slots__: + assert getattr(poll_media, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(poll_media)) == len(set(mro_slots(poll_media))), "duplicate slot" + + def test_de_json(self): + json_dict = { + "animation": self.animation.to_dict(), + "audio": self.audio.to_dict(), + "document": self.document.to_dict(), + "live_photo": self.live_photo.to_dict(), + "location": self.location.to_dict(), + "photo": [photo.to_dict() for photo in self.photo], + "sticker": self.sticker.to_dict(), + "venue": self.venue.to_dict(), + "video": self.video.to_dict(), + } + poll_media = PollMedia.de_json(json_dict, None) + + assert poll_media.api_kwargs == {} + assert poll_media.animation == self.animation + assert poll_media.audio == self.audio + assert poll_media.document == self.document + assert poll_media.live_photo == self.live_photo + assert poll_media.location == self.location + assert poll_media.photo == self.photo + assert poll_media.sticker == self.sticker + assert poll_media.venue == self.venue + assert poll_media.video == self.video + + def test_to_dict(self, poll_media): + poll_media_dict = poll_media.to_dict() + + assert isinstance(poll_media_dict, dict) + assert poll_media_dict["animation"] == poll_media.animation.to_dict() + assert poll_media_dict["audio"] == poll_media.audio.to_dict() + assert poll_media_dict["document"] == poll_media.document.to_dict() + assert poll_media_dict["live_photo"] == poll_media.live_photo.to_dict() + assert poll_media_dict["location"] == poll_media.location.to_dict() + assert poll_media_dict["photo"] == [photo.to_dict() for photo in poll_media.photo] + assert poll_media_dict["sticker"] == poll_media.sticker.to_dict() + assert poll_media_dict["venue"] == poll_media.venue.to_dict() + assert poll_media_dict["video"] == poll_media.video.to_dict() + + def test_equality(self): + a = PollMedia(photo=self.photo) + b = PollMedia(photo=self.photo) + c = PollMedia(photo=(PhotoSize("file_id", "other_file_unique_id", 1, 1),)) + d = PollMedia(video=self.video) + e = PollOption("text", 1, persistent_id="persistent_id") + + assert a == b + assert hash(a) == hash(b) + + assert a != d + assert hash(a) != hash(d) + + assert a != c + assert hash(a) != hash(c) + + assert a != e + assert hash(a) != hash(e) + + @pytest.fixture(scope="module") def poll_option(): out = PollOption( @@ -121,6 +264,8 @@ def poll_option(): added_by_user=PollOptionTestBase.added_by_user, added_by_chat=PollOptionTestBase.added_by_chat, addition_date=PollOptionTestBase.addition_date, + persistent_id=PollOptionTestBase.persistent_id, + media=PollOptionTestBase.media, ) out._unfreeze() return out @@ -136,6 +281,8 @@ class PollOptionTestBase: added_by_user = User(1, "test_user", False) added_by_chat = Chat(1, "test_chat") addition_date = dtm.datetime.now(dtm.timezone.utc) + persistent_id = "persistent_id" + media = PollMedia(location=Location(123, 456)) class TestPollOptionWithoutRequest(PollOptionTestBase): @@ -152,6 +299,8 @@ def test_de_json(self): "added_by_user": self.added_by_user.to_dict(), "added_by_chat": self.added_by_chat.to_dict(), "addition_date": to_timestamp(self.addition_date), + "persistent_id": self.persistent_id, + "media": self.media.to_dict(), } poll_option = PollOption.de_json(json_dict, None) assert poll_option.api_kwargs == {} @@ -162,6 +311,8 @@ def test_de_json(self): assert poll_option.added_by_user == self.added_by_user assert poll_option.added_by_chat == self.added_by_chat assert abs((poll_option.addition_date - self.addition_date).total_seconds()) < 1 + assert poll_option.persistent_id == self.persistent_id + assert poll_option.media == self.media def test_to_dict(self, poll_option): poll_option_dict = poll_option.to_dict() @@ -175,6 +326,8 @@ def test_to_dict(self, poll_option): assert poll_option_dict["added_by_user"] == poll_option.added_by_user.to_dict() assert poll_option_dict["added_by_chat"] == poll_option.added_by_chat.to_dict() assert poll_option_dict["addition_date"] == to_timestamp(poll_option.addition_date) + assert poll_option_dict["persistent_id"] == poll_option.persistent_id + assert poll_option_dict["media"] == poll_option.media.to_dict() def test_parse_entity(self, poll_option): entity = MessageEntity(MessageEntity.BOLD, 0, 4) @@ -190,12 +343,29 @@ def test_parse_entities(self, poll_option): assert poll_option.parse_entities(MessageEntity.BOLD) == {entity: "test"} assert poll_option.parse_entities() == {entity: "test", entity_2: "option"} + def test_persistent_id_required_workaround(self): + # tags: deprecated NEXT.VERSION, bot api 9.6 + with pytest.raises(TypeError, match="`persistent_id` is a required"): + PollOption(self.text, self.voter_count) + def test_equality(self): - a = PollOption("text", 1) - b = PollOption("text", 1) - c = PollOption("text_1", 1) - d = PollOption("text", 2) - e = Poll(123, "question", ["O1", "O2"], 1, False, True, Poll.REGULAR, True) + a = PollOption("text", 1, persistent_id="persistent_id") + b = PollOption("text", 1, persistent_id="persistent_id") + c = PollOption("other_text", 1, persistent_id="persistent_id") + d = PollOption("text", 1 + 9, persistent_id="persistent_id") + e = PollOption("text", 1, persistent_id="other_persistent_id") + f = Poll( + 123, + "question", + ["O1", "O2"], + 1, + False, + True, + Poll.REGULAR, + True, + allows_revoting=True, + members_only=True, + ) assert a == b assert hash(a) == hash(b) @@ -209,6 +379,9 @@ def test_equality(self): assert a != e assert hash(a) != hash(e) + assert a != f + assert hash(a) != hash(f) + @pytest.fixture(scope="module") def poll_answer(): @@ -257,13 +430,20 @@ def test_to_dict(self, poll_answer): assert poll_answer_dict["voter_chat"] == poll_answer.voter_chat.to_dict() assert poll_answer_dict["option_persistent_ids"] == list(poll_answer.option_persistent_ids) + def test_persistent_id_required_workaround(self): + # tags: deprecated NEXT.VERSION, bot api 9.6 + with pytest.raises(TypeError, match="`option_persistent_ids` is a required"): + PollAnswer(poll_id=123, option_ids=[2], user=self.user, voter_chat=self.voter_chat) + def test_equality(self): - a = PollAnswer(123, [2], self.user, self.voter_chat) - b = PollAnswer(123, [2], self.user, Chat(1, "")) - c = PollAnswer(123, [2], User(1, "first", False), self.voter_chat) - d = PollAnswer(123, [1, 2], self.user, self.voter_chat) - e = PollAnswer(456, [2], self.user, self.voter_chat) - f = PollOption("Text", 1) + a = PollAnswer(123, [2], self.user, self.voter_chat, option_persistent_ids=["2"]) + b = PollAnswer(123, [2], self.user, Chat(1, ""), option_persistent_ids=["2"]) + c = PollAnswer( + 123, [2], User(1, "first", False), self.voter_chat, option_persistent_ids=["2"] + ) + d = PollAnswer(123, [1, 2], self.user, self.voter_chat, option_persistent_ids=["1", "2"]) + e = PollAnswer(456, [2], self.user, self.voter_chat, option_persistent_ids=["2"]) + f = PollOption("Text", 1, persistent_id="persistent_id") assert a == b assert hash(a) == hash(b) @@ -298,9 +478,13 @@ def poll(): close_date=PollTestBase.close_date, question_entities=PollTestBase.question_entities, allows_revoting=PollTestBase.allows_revoting, + members_only=PollTestBase.members_only, correct_option_ids=PollTestBase.correct_option_ids, description=PollTestBase.description, description_entities=PollTestBase.description_entities, + country_codes=PollTestBase.country_codes, + media=PollTestBase.media, + explanation_media=PollTestBase.explanation_media, ) poll._unfreeze() return poll @@ -309,12 +493,16 @@ def poll(): class PollTestBase: id_ = "id" question = "Test Question?" - options = [PollOption("test", 10), PollOption("test2", 11)] + options = [ + PollOption("test", 10, persistent_id="persistent_id"), + PollOption("test2", 11, persistent_id="persistent_id_2"), + ] total_voter_count = 0 is_closed = True is_anonymous = False type = Poll.REGULAR allows_multiple_answers = True + members_only = True explanation = ( b"\\U0001f469\\u200d\\U0001f469\\u200d\\U0001f467" b"\\u200d\\U0001f467\\U0001f431http://google.com" @@ -330,6 +518,9 @@ class PollTestBase: correct_option_ids = [1, 2] description = "description" description_entities = [MessageEntity(MessageEntity.ITALIC, 0, 11)] + country_codes = ["AB", "CD"] + media = PollMedia(document=Document("file_id", "file_unique_id", "file_name", 42)) + explanation_media = PollMedia(animation=Animation("blah", "unique_id", 320, 180, 1)) class TestPollWithoutRequest(PollTestBase): @@ -349,9 +540,13 @@ def test_de_json(self, offline_bot): "close_date": to_timestamp(self.close_date), "question_entities": [e.to_dict() for e in self.question_entities], "allows_revoting": self.allows_revoting, + "members_only": self.members_only, "correct_option_ids": self.correct_option_ids, "description": self.description, "description_entities": [e.to_dict() for e in self.description_entities], + "country_codes": self.country_codes, + "media": self.media.to_dict(), + "explanation_media": self.explanation_media.to_dict(), } poll = Poll.de_json(json_dict, offline_bot) assert poll.api_kwargs == {} @@ -368,6 +563,7 @@ def test_de_json(self, offline_bot): assert poll.is_anonymous == self.is_anonymous assert poll.type == self.type assert poll.allows_multiple_answers == self.allows_multiple_answers + assert poll.members_only == self.members_only assert poll.explanation == self.explanation assert poll.explanation_entities == tuple(self.explanation_entities) assert poll._open_period == self.open_period @@ -378,6 +574,9 @@ def test_de_json(self, offline_bot): assert poll.correct_option_ids == tuple(self.correct_option_ids) assert poll.description == self.description assert poll.description_entities == tuple(self.description_entities) + assert poll.country_codes == tuple(self.country_codes) + assert poll.media == self.media + assert poll.explanation_media == self.explanation_media def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): json_dict = { @@ -395,9 +594,13 @@ def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): "close_date": to_timestamp(self.close_date), "question_entities": [e.to_dict() for e in self.question_entities], "allows_revoting": self.allows_revoting, + "members_only": self.members_only, "correct_option_ids": self.correct_option_ids, "description": self.description, "description_entities": [e.to_dict() for e in self.description_entities], + "country_codes": self.country_codes, + "media": self.media.to_dict(), + "explanation_media": self.explanation_media.to_dict(), } poll_raw = Poll.de_json(json_dict, raw_bot) @@ -426,6 +629,7 @@ def test_to_dict(self, poll): assert poll_dict["is_anonymous"] == poll.is_anonymous assert poll_dict["type"] == poll.type assert poll_dict["allows_multiple_answers"] == poll.allows_multiple_answers + assert poll_dict["members_only"] == poll.members_only assert poll_dict["explanation"] == poll.explanation assert poll_dict["explanation_entities"] == [poll.explanation_entities[0].to_dict()] assert poll_dict["open_period"] == int(self.open_period.total_seconds()) @@ -437,6 +641,9 @@ def test_to_dict(self, poll): assert poll_dict["description_entities"] == [ e.to_dict() for e in poll.description_entities ] + assert poll_dict["country_codes"] == list(poll.country_codes) + assert poll_dict["media"] == poll.media.to_dict() + assert poll_dict["explanation_media"] == poll.explanation_media.to_dict() def test_time_period_properties(self, PTB_TIMEDELTA, poll): if PTB_TIMEDELTA: @@ -473,14 +680,79 @@ def test_correct_option_id_deprecated(self, recwarn, poll): PollTestBase.type, PollTestBase.allows_multiple_answers, correct_option_id=1, + allows_revoting=PollTestBase.allows_revoting, + members_only=PollTestBase.members_only, ) assert poll.correct_option_ids == (1,) + def test_allows_revoting_required_workaround(self): + # tags: deprecated NEXT.VERSION, bot api 9.6 + with pytest.raises(TypeError, match="`allows_revoting` is a required"): + Poll( + self.id_, + self.question, + self.options, + self.total_voter_count, + self.is_closed, + self.is_anonymous, + self.type, + self.allows_multiple_answers, + members_only=self.members_only, + ) + + def test_members_only_required_workaround(self): + # tags: deprecated NEXT.VERSION, bot api 10.0 + with pytest.raises(TypeError, match="`members_only` is a required"): + Poll( + self.id_, + self.question, + self.options, + self.total_voter_count, + self.is_closed, + self.is_anonymous, + self.type, + self.allows_multiple_answers, + allows_revoting=self.allows_revoting, + ) + def test_equality(self): - a = Poll(123, "question", ["O1", "O2"], 1, False, True, Poll.REGULAR, True) - b = Poll(123, "question", ["o1", "o2"], 1, True, False, Poll.REGULAR, True) - c = Poll(456, "question", ["o1", "o2"], 1, True, False, Poll.REGULAR, True) - d = PollOption("Text", 1) + a = Poll( + 123, + "question", + ["O1", "O2"], + 1, + False, + True, + Poll.REGULAR, + True, + allows_revoting=True, + members_only=True, + ) + b = Poll( + 123, + "question", + ["o1", "o2"], + 1, + True, + False, + Poll.REGULAR, + True, + allows_revoting=False, + members_only=False, + ) + c = Poll( + 456, + "question", + ["o1", "o2"], + 1, + True, + False, + Poll.REGULAR, + True, + allows_revoting=True, + members_only=True, + ) + d = PollOption("Text", 1, persistent_id="persistent_id") assert a == b assert hash(a) == hash(b) @@ -501,6 +773,8 @@ def test_enum_init(self): is_closed=False, is_anonymous=False, allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ) assert poll.type == "foo" poll = Poll( @@ -512,6 +786,8 @@ def test_enum_init(self): is_closed=False, is_anonymous=False, allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ) assert poll.type is PollType.QUIZ @@ -525,12 +801,14 @@ def test_parse_explanation_entity(self, poll): Poll( "id", "question", - [PollOption("text", voter_count=0)], + [PollOption("text", voter_count=0, persistent_id="persistent_id")], total_voter_count=0, is_closed=False, is_anonymous=False, type=Poll.QUIZ, allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ).parse_explanation_entity(entity) def test_parse_explanation_entities(self, poll): @@ -545,12 +823,14 @@ def test_parse_explanation_entities(self, poll): Poll( "id", "question", - [PollOption("text", voter_count=0)], + [PollOption("text", voter_count=0, persistent_id="persistent_id")], total_voter_count=0, is_closed=False, is_anonymous=False, type=Poll.QUIZ, allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ).parse_explanation_entities() def test_parse_question_entity(self, poll): @@ -576,12 +856,14 @@ def test_parse_description_entity(self, poll): Poll( "id", "question", - [PollOption("text", voter_count=0)], + [PollOption("text", voter_count=0, persistent_id="persistent_id")], total_voter_count=0, is_closed=False, is_anonymous=False, type=Poll.QUIZ, allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ).parse_description_entity(entity) def test_parse_description_entities(self, poll): @@ -595,12 +877,14 @@ def test_parse_description_entities(self, poll): Poll( "id", "question", - [PollOption("text", voter_count=0)], + [PollOption("text", voter_count=0, persistent_id="persistent_id")], total_voter_count=0, is_closed=False, is_anonymous=False, type=Poll.QUIZ, allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ).parse_description_entities() diff --git a/tests/test_reply.py b/tests/test_reply.py index 1822fff19f4..fa544845522 100644 --- a/tests/test_reply.py +++ b/tests/test_reply.py @@ -29,6 +29,7 @@ ExternalReplyInfo, Giveaway, LinkPreviewOptions, + LivePhoto, MessageEntity, MessageOriginUser, PaidMediaInfo, @@ -50,6 +51,7 @@ def external_reply_info(): giveaway=ExternalReplyInfoTestBase.giveaway, paid_media=ExternalReplyInfoTestBase.paid_media, checklist=ExternalReplyInfoTestBase.checklist, + live_photo=ExternalReplyInfoTestBase.live_photo, ) @@ -73,6 +75,16 @@ class ExternalReplyInfoTestBase: ChecklistTask(text="Item 2", id=2), ], ) + live_photo = LivePhoto( + file_id="file_id", + file_unique_id="file_unique_id", + width=100, + height=100, + duration=dtm.timedelta(seconds=10), + photo=[], + mime_type="image/jpeg", + file_size=1024, + ) class TestExternalReplyInfoWithoutRequest(ExternalReplyInfoTestBase): @@ -92,6 +104,7 @@ def test_de_json(self, offline_bot): "giveaway": self.giveaway.to_dict(), "paid_media": self.paid_media.to_dict(), "checklist": self.checklist.to_dict(), + "live_photo": self.live_photo.to_dict(), } external_reply_info = ExternalReplyInfo.de_json(json_dict, offline_bot) @@ -104,6 +117,7 @@ def test_de_json(self, offline_bot): assert external_reply_info.giveaway == self.giveaway assert external_reply_info.paid_media == self.paid_media assert external_reply_info.checklist == self.checklist + assert external_reply_info.live_photo == self.live_photo def test_to_dict(self, external_reply_info): ext_reply_info_dict = external_reply_info.to_dict() @@ -116,6 +130,7 @@ def test_to_dict(self, external_reply_info): assert ext_reply_info_dict["giveaway"] == self.giveaway.to_dict() assert ext_reply_info_dict["paid_media"] == self.paid_media.to_dict() assert ext_reply_info_dict["checklist"] == self.checklist.to_dict() + assert ext_reply_info_dict["live_photo"] == self.live_photo.to_dict() def test_equality(self, external_reply_info): a = external_reply_info diff --git a/tests/test_sentguestmessage.py b/tests/test_sentguestmessage.py new file mode 100644 index 00000000000..0208342fb57 --- /dev/null +++ b/tests/test_sentguestmessage.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 SentGuestMessage +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def sent_guest_message(): + return SentGuestMessage(inline_message_id=SentGuestMessageTestBase.inline_message_id) + + +class SentGuestMessageTestBase: + inline_message_id = "123" + + +class TestSentGuestMessageWithoutRequest(SentGuestMessageTestBase): + def test_slot_behaviour(self, sent_guest_message): + inst = sent_guest_message + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_to_dict(self, sent_guest_message): + sent_guest_message_dict = sent_guest_message.to_dict() + + assert isinstance(sent_guest_message_dict, dict) + assert sent_guest_message_dict["inline_message_id"] == self.inline_message_id + + def test_de_json(self, offline_bot): + data = {"inline_message_id": self.inline_message_id} + m = SentGuestMessage.de_json(data, None) + assert m.api_kwargs == {} + assert m.inline_message_id == self.inline_message_id + + def test_equality(self): + a = SentGuestMessage(self.inline_message_id) + b = SentGuestMessage(self.inline_message_id) + c = SentGuestMessage("not_inline_message_id") + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a != c + assert hash(a) != hash(c) diff --git a/tests/test_update.py b/tests/test_update.py index 7b034090a0e..cbaa1d4b66a 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -156,6 +156,14 @@ user=User(1, "creator", True), bot=User(2, "bot", True), ) +guest_message = Message( + 1, + dtm.datetime.utcnow(), + Chat(1, ""), + User(1, "", False), + sender_chat=Chat(1, ""), +) + params = [ {"message": message}, @@ -171,17 +179,31 @@ ) }, {"pre_checkout_query": PreCheckoutQuery("id", User(1, "", False), "", 0, "")}, - {"poll": Poll("id", "?", [PollOption(".", 1)], False, False, False, Poll.REGULAR, True)}, { - "poll_answer": PollAnswer( + "poll": Poll( "id", - [1], - User( + "?", + [PollOption(text=".", voter_count=1, persistent_id="persistent_id")], + False, + False, + False, + Poll.REGULAR, + True, + allows_revoting=True, + members_only=True, + ) + }, + { + "poll_answer": PollAnswer( + poll_id="id", + option_ids=[1], + option_persistent_ids=["1"], + user=User( 1, "", False, ), - Chat(1, ""), + voter_chat=Chat(1, ""), ) }, {"my_chat_member": chat_member_updated}, @@ -197,6 +219,7 @@ {"edited_business_message": business_message}, {"purchased_paid_media": purchased_paid_media}, {"managed_bot": managed_bot}, + {"guest_message": guest_message}, # Must be last to conform with `ids` below! {"callback_query": CallbackQuery(1, User(1, "", False), "chat")}, ] @@ -226,6 +249,7 @@ "edited_business_message", "purchased_paid_media", "managed_bot", + "guest_message", ) ids = (*all_types, "callback_query_without_message") @@ -332,7 +356,7 @@ def test_effective_user(self, update): def test_effective_sender_non_anonymous(self, update): update = deepcopy(update) # Simulate 'Remain anonymous' being turned off - if message := (update.message or update.edited_message): + if message := (update.message or update.edited_message or update.guest_message): message._unfreeze() message.sender_chat = None elif reaction := (update.message_reaction): @@ -365,7 +389,7 @@ def test_effective_sender_non_anonymous(self, update): def test_effective_sender_anonymous(self, update): update = deepcopy(update) # Simulate 'Remain anonymous' being turned on - if message := (update.message or update.edited_message): + if message := (update.message or update.edited_message or update.guest_message): message._unfreeze() message.from_user = None elif reaction := (update.message_reaction): @@ -391,6 +415,7 @@ def test_effective_sender_anonymous(self, update): or update.edited_channel_post or update.message_reaction or update.poll_answer + or update.guest_message ): assert isinstance(sender, Chat) else: diff --git a/tests/test_user.py b/tests/test_user.py index 24f8ffe8c8d..197dbbe3967 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -47,6 +47,7 @@ def json_dict(): "has_topics_enabled": UserTestBase.has_topics_enabled, "allows_users_to_create_topics": UserTestBase.allows_users_to_create_topics, "can_manage_bots": UserTestBase.can_manage_bots, + "supports_guest_queries": UserTestBase.supports_guest_queries, } @@ -69,6 +70,7 @@ def user(bot): has_topics_enabled=UserTestBase.has_topics_enabled, allows_users_to_create_topics=UserTestBase.allows_users_to_create_topics, can_manage_bots=UserTestBase.can_manage_bots, + supports_guest_queries=UserTestBase.supports_guest_queries, ) user.set_bot(bot) user._unfreeze() @@ -92,6 +94,7 @@ class UserTestBase: has_topics_enabled = False allows_users_to_create_topics = False can_manage_bots = True + supports_guest_queries = False class TestUserWithoutRequest(UserTestBase): @@ -120,6 +123,7 @@ def test_de_json(self, json_dict, offline_bot): assert user.has_topics_enabled == self.has_topics_enabled assert user.allows_users_to_create_topics == self.allows_users_to_create_topics assert user.can_manage_bots == self.can_manage_bots + assert user.supports_guest_queries == self.supports_guest_queries def test_to_dict(self, user): user_dict = user.to_dict() @@ -141,6 +145,7 @@ def test_to_dict(self, user): assert user_dict["has_topics_enabled"] == user.has_topics_enabled assert user_dict["allows_users_to_create_topics"] == user.allows_users_to_create_topics assert user_dict["can_manage_bots"] == user.can_manage_bots + assert user_dict["supports_guest_queries"] == user.supports_guest_queries def test_equality(self): a = User(self.id_, self.first_name, self.is_bot, self.last_name) @@ -291,6 +296,21 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(user.get_bot(), "send_photo", make_assertion) assert await user.send_photo("test_photo") + async def test_instance_method_send_live_photo(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == user.id + and kwargs["live_photo"] == "test_live_photo" + and kwargs["photo"] == "test_photo" + ) + + assert check_shortcut_signature(User.send_live_photo, Bot.send_live_photo, ["chat_id"], []) + assert await check_shortcut_call(user.send_live_photo, user.get_bot(), "send_live_photo") + assert await check_defaults_handling(user.send_live_photo, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "send_live_photo", make_assertion) + assert await user.send_live_photo("test_live_photo", "test_photo") + async def test_instance_method_send_media_group(self, monkeypatch, user): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == user.id and kwargs["media"] == "test_media_group" @@ -926,3 +946,115 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(user.get_bot(), "replace_managed_bot_token", make_assertion) assert await user.replace_token() + + async def test_instance_method_get_managed_bot_access_settings(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return kwargs["user_id"] == user.id + + assert check_shortcut_signature( + user.get_managed_bot_access_settings, + Bot.get_managed_bot_access_settings, + ["user_id"], + [], + ) + assert await check_shortcut_call( + user.get_managed_bot_access_settings, + user.get_bot(), + "get_managed_bot_access_settings", + ) + assert await check_defaults_handling(user.get_managed_bot_access_settings, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "get_managed_bot_access_settings", make_assertion) + assert await user.get_managed_bot_access_settings() + + async def test_instance_method_set_managed_bot_access_settings(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return ( + kwargs["user_id"] == user.id + and kwargs["is_access_restricted"] is True + and kwargs["added_user_ids"] == [123] + ) + + assert check_shortcut_signature( + user.set_managed_bot_access_settings, + Bot.set_managed_bot_access_settings, + ["user_id"], + [], + ) + assert await check_shortcut_call( + user.set_managed_bot_access_settings, + user.get_bot(), + "set_managed_bot_access_settings", + ) + assert await check_defaults_handling(user.set_managed_bot_access_settings, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "set_managed_bot_access_settings", make_assertion) + assert await user.set_managed_bot_access_settings( + is_access_restricted=True, + added_user_ids=[123], + ) + + async def test_instance_method_get_personal_chat_messages(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return kwargs["user_id"] == user.id and kwargs["limit"] == 2 + + assert check_shortcut_signature( + user.get_personal_chat_messages, + Bot.get_user_personal_chat_messages, + ["user_id"], + [], + ) + assert await check_shortcut_call( + user.get_personal_chat_messages, + user.get_bot(), + "get_user_personal_chat_messages", + ) + assert await check_defaults_handling(user.get_personal_chat_messages, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "get_user_personal_chat_messages", make_assertion) + assert await user.get_personal_chat_messages(limit=2) + + async def test_instance_method_delete_reaction(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return ( + kwargs["user_id"] == user.id + and kwargs["chat_id"] == 1234 + and kwargs["message_id"] == 123 + and kwargs["actor_chat_id"] == 42 + ) + + assert check_shortcut_signature( + user.delete_reaction, Bot.delete_message_reaction, ["user_id"], [] + ) + assert await check_shortcut_call( + user.delete_reaction, + user.get_bot(), + "delete_message_reaction", + shortcut_kwargs=["user_id"], + ) + assert await check_defaults_handling(user.delete_reaction, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "delete_message_reaction", make_assertion) + assert await user.delete_reaction(chat_id=1234, message_id=123, actor_chat_id=42) + + async def test_instance_method_delete_all_reactions(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return ( + kwargs["user_id"] == user.id + and kwargs["chat_id"] == 1234 + and kwargs["actor_chat_id"] == 42 + ) + + assert check_shortcut_signature( + user.delete_all_reactions, Bot.delete_all_message_reactions, ["user_id"], [] + ) + assert await check_shortcut_call( + user.delete_all_reactions, + user.get_bot(), + "delete_all_message_reactions", + shortcut_kwargs=["user_id"], + ) + assert await check_defaults_handling(user.delete_all_reactions, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "delete_all_message_reactions", make_assertion) + assert await user.delete_all_reactions(chat_id=1234, actor_chat_id=42) diff --git a/uv.lock b/uv.lock index 1b6acb434db..3c76a170d28 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + [[package]] name = "accessible-pygments" version = "0.0.5" @@ -1653,7 +1657,7 @@ all = [ { name = "sphinx-copybutton", specifier = "==0.5.2" }, { name = "sphinx-inline-tabs", specifier = "==2025.12.21.14" }, { name = "sphinx-paramlinks", specifier = "==0.6.0" }, - { name = "sphinxcontrib-mermaid", specifier = "==2.0.1" }, + { name = "sphinxcontrib-mermaid", specifier = "==2.0.2" }, { name = "tzdata" }, ] docs = [ @@ -1665,7 +1669,7 @@ docs = [ { name = "sphinx-copybutton", specifier = "==0.5.2" }, { name = "sphinx-inline-tabs", specifier = "==2025.12.21.14" }, { name = "sphinx-paramlinks", specifier = "==0.6.0" }, - { name = "sphinxcontrib-mermaid", specifier = "==2.0.1" }, + { name = "sphinxcontrib-mermaid", specifier = "==2.0.2" }, ] linting = [ { name = "mypy", specifier = "==1.20.2" }, @@ -2065,7 +2069,7 @@ wheels = [ [[package]] name = "sphinxcontrib-mermaid" -version = "2.0.1" +version = "2.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, @@ -2074,9 +2078,9 @@ dependencies = [ { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2b/ae/999891de292919b66ea34f2c22fc22c9be90ab3536fbc0fca95716277351/sphinxcontrib_mermaid-2.0.1.tar.gz", hash = "sha256:a21a385a059a6cafd192aa3a586b14bf5c42721e229db67b459dc825d7f0a497", size = 19839, upload-time = "2026-03-05T14:10:41.901Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/3a1cc926da8c563c58ddc124a7b3fe5ccadcae96c96e3a6f8ac3653a210a/sphinxcontrib_mermaid-2.0.2.tar.gz", hash = "sha256:f09576c78ca93fa0e3034fd9c45aaffa7c44ab449de9c43b8b8d262afe52bc66", size = 19265, upload-time = "2026-05-05T13:59:02.959Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/46/25d64bcd7821c8d6f1080e1c43d5fcdfc442a18f759a230b5ccdc891093e/sphinxcontrib_mermaid-2.0.1-py3-none-any.whl", hash = "sha256:9dca7fbe827bad5e7e2b97c4047682cfd26e3e07398cfdc96c7a8842ae7f06e7", size = 14064, upload-time = "2026-03-05T14:10:40.533Z" }, + { url = "https://files.pythonhosted.org/packages/16/8d/93be7e0f7fa915a576859b3bfac7a7baa3303181c44d7db7eefbd3e8a69f/sphinxcontrib_mermaid-2.0.2-py3-none-any.whl", hash = "sha256:d862e514991279fb4816302c5cfe167d2557bf3ce7125ae0cb47dac80a0f46ce", size = 14094, upload-time = "2026-05-05T13:59:01.585Z" }, ] [[package]]