diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index e67eee83d26..1f45f0ab895 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -3,9 +3,11 @@ on: pull_request: branches: - master + push: branches: - master + schedule: # Run monday and friday morning at 03:07 - odd time to spread load on GitHub Actions - cron: '7 3 * * 1,5' diff --git a/README.rst b/README.rst index d2bc0229f25..b40ac1331ee 100644 --- a/README.rst +++ b/README.rst @@ -14,7 +14,7 @@ :target: https://pypi.org/project/python-telegram-bot/ :alt: Supported Python versions -.. image:: https://img.shields.io/badge/Bot%20API-6.9-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-7.0-blue?logo=telegram :target: https://core.telegram.org/bots/api-changelog :alt: Supported Bot API versions @@ -93,7 +93,7 @@ Installing both ``python-telegram-bot`` and ``python-telegram-bot-raw`` in conju Telegram API support ==================== -All types and methods of the Telegram Bot API **6.9** are supported. +All types and methods of the Telegram Bot API **7.0** are supported. Installing ========== diff --git a/README_RAW.rst b/README_RAW.rst index f4e574b6986..5c3f7f60dfa 100644 --- a/README_RAW.rst +++ b/README_RAW.rst @@ -14,7 +14,7 @@ :target: https://pypi.org/project/python-telegram-bot-raw/ :alt: Supported Python versions -.. image:: https://img.shields.io/badge/Bot%20API-6.9-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-7.0-blue?logo=telegram :target: https://core.telegram.org/bots/api-changelog :alt: Supported Bot API versions @@ -89,7 +89,7 @@ Installing both ``python-telegram-bot`` and ``python-telegram-bot-raw`` in conju Telegram API support ==================== -All types and methods of the Telegram Bot API **6.9** are supported. +All types and methods of the Telegram Bot API **7.0** are supported. Installing ========== diff --git a/docs/auxil/tg_const_role.py b/docs/auxil/tg_const_role.py index e2962b21802..e5de978b20c 100644 --- a/docs/auxil/tg_const_role.py +++ b/docs/auxil/tg_const_role.py @@ -15,6 +15,7 @@ # # 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 datetime from enum import Enum from docutils.nodes import Element @@ -71,6 +72,12 @@ def process_link( if isinstance(value, tuple) and target in ( "telegram.constants.BOT_API_VERSION_INFO", "telegram.__version_info__", + ): + return str(value), target + if ( + isinstance(value, datetime.datetime) + and value == telegram.constants.ZERO_DATE + and target in ("telegram.constants.ZERO_DATE",) ): return repr(value), target sphinx_logger.warning( diff --git a/docs/source/inclusions/bot_methods.rst b/docs/source/inclusions/bot_methods.rst index a5428f2f0b0..1f05f11ff11 100644 --- a/docs/source/inclusions/bot_methods.rst +++ b/docs/source/inclusions/bot_methods.rst @@ -49,8 +49,12 @@ - Used for sending voice messages * - :meth:`~telegram.Bot.copy_message` - Used for copying the contents of an arbitrary message + * - :meth:`~telegram.Bot.copy_messages` + - Used for copying the contents of an multiple arbitrary messages * - :meth:`~telegram.Bot.forward_message` - Used for forwarding messages + * - :meth:`~telegram.Bot.forward_messages` + - Used for forwarding multiple messages at once .. raw:: html @@ -76,6 +80,10 @@ - Used for answering a shipping query * - :meth:`~telegram.Bot.answer_web_app_query` - Used for answering a web app query + * - :meth:`~telegram.Bot.delete_message` + - Used for deleting messages. + * - :meth:`~telegram.Bot.delete_messages` + - Used for deleting multiple messages as once. * - :meth:`~telegram.Bot.edit_message_caption` - Used for editing captions * - :meth:`~telegram.Bot.edit_message_media` @@ -88,8 +96,8 @@ - Used for editing text messages * - :meth:`~telegram.Bot.stop_poll` - Used for stopping the running poll - * - :meth:`~telegram.Bot.delete_message` - - Used for deleting messages. + * - :meth:`~telegram.Bot.set_message_reaction` + - Used for setting reactions on messages .. raw:: html @@ -157,6 +165,8 @@ - Used for getting the number of members in a chat * - :meth:`~telegram.Bot.get_chat_member` - Used for getting a member of a chat + * - :meth:`~telegram.Bot.get_user_chat_boosts` + - Used for getting the list of boosts added to a chat * - :meth:`~telegram.Bot.leave_chat` - Used for leaving a chat diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index a2e3de62249..c5159a71e0c 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -21,6 +21,13 @@ Available Types telegram.callbackquery telegram.chat telegram.chatadministratorrights + telegram.chatboost + telegram.chatboostremoved + telegram.chatboostsource + telegram.chatboostsourcegiftcode + telegram.chatboostsourcegiveaway + telegram.chatboostsourcepremium + telegram.chatboostupdated telegram.chatinvitelink telegram.chatjoinrequest telegram.chatlocation @@ -38,6 +45,7 @@ Available Types telegram.contact telegram.dice telegram.document + telegram.externalreplyinfo telegram.file telegram.forcereply telegram.forumtopic @@ -47,6 +55,11 @@ Available Types telegram.forumtopicreopened telegram.generalforumtopichidden telegram.generalforumtopicunhidden + telegram.giveaway + telegram.giveawaycompleted + telegram.giveawaycreated + telegram.giveawaywinners + telegram.inaccessiblemessage telegram.inlinekeyboardbutton telegram.inlinekeyboardmarkup telegram.inputfile @@ -61,8 +74,11 @@ Available Types telegram.keyboardbuttonpolltype telegram.keyboardbuttonrequestchat telegram.keyboardbuttonrequestuser + telegram.keyboardbuttonrequestusers + telegram.linkpreviewoptions telegram.location telegram.loginurl + telegram.maybeinaccessiblemessage telegram.menubutton telegram.menubuttoncommands telegram.menubuttondefault @@ -71,21 +87,36 @@ Available Types telegram.messageautodeletetimerchanged telegram.messageentity telegram.messageid + telegram.messageorigin + telegram.messageoriginchannel + telegram.messageoriginchat + telegram.messageoriginhiddenuser + telegram.messageoriginuser + telegram.messagereactioncountupdated + telegram.messagereactionupdated telegram.photosize telegram.poll telegram.pollanswer telegram.polloption telegram.proximityalerttriggered + telegram.reactioncount + telegram.reactiontype + telegram.reactiontypecustomemoji + telegram.reactiontypeemoji telegram.replykeyboardmarkup telegram.replykeyboardremove + telegram.replyparameters telegram.sentwebappmessage telegram.story telegram.switchinlinequerychosenchat telegram.telegramobject + telegram.textquote telegram.update telegram.user + telegram.userchatboosts telegram.userprofilephotos telegram.usershared + telegram.usersshared telegram.venue telegram.video telegram.videochatended diff --git a/docs/source/telegram.chatboost.rst b/docs/source/telegram.chatboost.rst new file mode 100644 index 00000000000..68fa3bed5ac --- /dev/null +++ b/docs/source/telegram.chatboost.rst @@ -0,0 +1,8 @@ +ChatBoost +========= + +.. versionadded:: NEXT.VERSION + +.. autoclass:: telegram.ChatBoost + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.chatboostremoved.rst b/docs/source/telegram.chatboostremoved.rst new file mode 100644 index 00000000000..85cd3877364 --- /dev/null +++ b/docs/source/telegram.chatboostremoved.rst @@ -0,0 +1,8 @@ +ChatBoostRemoved +================ + +.. versionadded:: NEXT.VERSION + +.. autoclass:: telegram.ChatBoostRemoved + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.chatboostsource.rst b/docs/source/telegram.chatboostsource.rst new file mode 100644 index 00000000000..2198fce4165 --- /dev/null +++ b/docs/source/telegram.chatboostsource.rst @@ -0,0 +1,8 @@ +ChatBoostSource +=============== + +.. versionadded:: NEXT.VERSION + +.. autoclass:: telegram.ChatBoostSource + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.chatboostsourcegiftcode.rst b/docs/source/telegram.chatboostsourcegiftcode.rst new file mode 100644 index 00000000000..8970c679a38 --- /dev/null +++ b/docs/source/telegram.chatboostsourcegiftcode.rst @@ -0,0 +1,8 @@ +ChatBoostSourceGiftCode +======================= + +.. versionadded:: NEXT.VERSION + +.. autoclass:: telegram.ChatBoostSourceGiftCode + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.chatboostsourcegiveaway.rst b/docs/source/telegram.chatboostsourcegiveaway.rst new file mode 100644 index 00000000000..d12eeb6bef0 --- /dev/null +++ b/docs/source/telegram.chatboostsourcegiveaway.rst @@ -0,0 +1,8 @@ +ChatBoostSourceGiveaway +======================= + +.. versionadded:: NEXT.VERSION + +.. autoclass:: telegram.ChatBoostSourceGiveaway + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.chatboostsourcepremium.rst b/docs/source/telegram.chatboostsourcepremium.rst new file mode 100644 index 00000000000..9affb7410ac --- /dev/null +++ b/docs/source/telegram.chatboostsourcepremium.rst @@ -0,0 +1,8 @@ +ChatBoostSourcePremium +====================== + +.. versionadded:: NEXT.VERSION + +.. autoclass:: telegram.ChatBoostSourcePremium + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.chatboostupdated.rst b/docs/source/telegram.chatboostupdated.rst new file mode 100644 index 00000000000..78c46defd9b --- /dev/null +++ b/docs/source/telegram.chatboostupdated.rst @@ -0,0 +1,8 @@ +ChatBoostUpdated +================ + +.. versionadded:: NEXT.VERSION + +.. autoclass:: telegram.ChatBoostUpdated + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.ext.chatboosthandler.rst b/docs/source/telegram.ext.chatboosthandler.rst new file mode 100644 index 00000000000..13994ec0c7b --- /dev/null +++ b/docs/source/telegram.ext.chatboosthandler.rst @@ -0,0 +1,8 @@ +ChatBoostHandler +================ + +.. versionadded:: NEXT.VERSION + +.. autoclass:: telegram.ext.ChatBoostHandler + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.ext.handlers-tree.rst b/docs/source/telegram.ext.handlers-tree.rst index b918d6a92dd..e5df80b2cc6 100644 --- a/docs/source/telegram.ext.handlers-tree.rst +++ b/docs/source/telegram.ext.handlers-tree.rst @@ -6,6 +6,7 @@ Handlers telegram.ext.basehandler telegram.ext.callbackqueryhandler + telegram.ext.chatboosthandler telegram.ext.chatjoinrequesthandler telegram.ext.chatmemberhandler telegram.ext.choseninlineresulthandler @@ -14,6 +15,7 @@ Handlers telegram.ext.filters telegram.ext.inlinequeryhandler telegram.ext.messagehandler + telegram.ext.messagereactionhandler telegram.ext.pollanswerhandler telegram.ext.pollhandler telegram.ext.precheckoutqueryhandler diff --git a/docs/source/telegram.ext.messagereactionhandler.rst b/docs/source/telegram.ext.messagereactionhandler.rst new file mode 100644 index 00000000000..1aad333ff1c --- /dev/null +++ b/docs/source/telegram.ext.messagereactionhandler.rst @@ -0,0 +1,6 @@ +MessageReactionHandler +====================== + +.. autoclass:: telegram.ext.MessageReactionHandler + :members: + :show-inheritance: diff --git a/docs/source/telegram.externalreplyinfo.rst b/docs/source/telegram.externalreplyinfo.rst new file mode 100644 index 00000000000..568bf07ef38 --- /dev/null +++ b/docs/source/telegram.externalreplyinfo.rst @@ -0,0 +1,6 @@ +ExternalReplyInfo +================= + +.. autoclass:: telegram.ExternalReplyInfo + :members: + :show-inheritance: diff --git a/docs/source/telegram.giveaway.rst b/docs/source/telegram.giveaway.rst new file mode 100644 index 00000000000..8d1d854985a --- /dev/null +++ b/docs/source/telegram.giveaway.rst @@ -0,0 +1,6 @@ +Giveaway +======== + +.. autoclass:: telegram.Giveaway + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.giveawaycompleted.rst b/docs/source/telegram.giveawaycompleted.rst new file mode 100644 index 00000000000..c89e9564e85 --- /dev/null +++ b/docs/source/telegram.giveawaycompleted.rst @@ -0,0 +1,6 @@ +GiveawayCompleted +================= + +.. autoclass:: telegram.GiveawayCompleted + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.giveawaycreated.rst b/docs/source/telegram.giveawaycreated.rst new file mode 100644 index 00000000000..f29de887751 --- /dev/null +++ b/docs/source/telegram.giveawaycreated.rst @@ -0,0 +1,6 @@ +GiveawayCreated +=============== + +.. autoclass:: telegram.GiveawayCreated + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.giveawaywinners.rst b/docs/source/telegram.giveawaywinners.rst new file mode 100644 index 00000000000..4be51e8502b --- /dev/null +++ b/docs/source/telegram.giveawaywinners.rst @@ -0,0 +1,6 @@ +GiveawayWinners +=============== + +.. autoclass:: telegram.GiveawayWinners + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.inaccessiblemessage.rst b/docs/source/telegram.inaccessiblemessage.rst new file mode 100644 index 00000000000..d65c8c42c71 --- /dev/null +++ b/docs/source/telegram.inaccessiblemessage.rst @@ -0,0 +1,6 @@ +InaccessibleMessage +=================== + +.. autoclass:: telegram.InaccessibleMessage + :members: + :show-inheritance: diff --git a/docs/source/telegram.keyboardbuttonrequestuser.rst b/docs/source/telegram.keyboardbuttonrequestuser.rst index f6e4c3608eb..24ebfa758a4 100644 --- a/docs/source/telegram.keyboardbuttonrequestuser.rst +++ b/docs/source/telegram.keyboardbuttonrequestuser.rst @@ -1,5 +1,5 @@ KeyboardButtonRequestUser -================================== +========================= .. autoclass:: telegram.KeyboardButtonRequestUser :members: diff --git a/docs/source/telegram.keyboardbuttonrequestusers.rst b/docs/source/telegram.keyboardbuttonrequestusers.rst new file mode 100644 index 00000000000..a56e9fb4316 --- /dev/null +++ b/docs/source/telegram.keyboardbuttonrequestusers.rst @@ -0,0 +1,6 @@ +KeyboardButtonRequestUsers +========================== + +.. autoclass:: telegram.KeyboardButtonRequestUsers + :members: + :show-inheritance: diff --git a/docs/source/telegram.linkpreviewoptions.rst b/docs/source/telegram.linkpreviewoptions.rst new file mode 100644 index 00000000000..53b46cdcf80 --- /dev/null +++ b/docs/source/telegram.linkpreviewoptions.rst @@ -0,0 +1,6 @@ +LinkPreviewOptions +================== + +.. autoclass:: telegram.LinkPreviewOptions + :members: + :show-inheritance: diff --git a/docs/source/telegram.maybeinaccessiblemessage.rst b/docs/source/telegram.maybeinaccessiblemessage.rst new file mode 100644 index 00000000000..920d757c487 --- /dev/null +++ b/docs/source/telegram.maybeinaccessiblemessage.rst @@ -0,0 +1,6 @@ +MaybeInaccessibleMessage +======================== + +.. autoclass:: telegram.MaybeInaccessibleMessage + :members: + :show-inheritance: diff --git a/docs/source/telegram.messageorigin.rst b/docs/source/telegram.messageorigin.rst new file mode 100644 index 00000000000..ed0cf6905e2 --- /dev/null +++ b/docs/source/telegram.messageorigin.rst @@ -0,0 +1,6 @@ +MessageOrigin +============= + +.. autoclass:: telegram.MessageOrigin + :members: + :show-inheritance: diff --git a/docs/source/telegram.messageoriginchannel.rst b/docs/source/telegram.messageoriginchannel.rst new file mode 100644 index 00000000000..bddd957a3f3 --- /dev/null +++ b/docs/source/telegram.messageoriginchannel.rst @@ -0,0 +1,6 @@ +MessageOriginChannel +==================== + +.. autoclass:: telegram.MessageOriginChannel + :members: + :show-inheritance: diff --git a/docs/source/telegram.messageoriginchat.rst b/docs/source/telegram.messageoriginchat.rst new file mode 100644 index 00000000000..928572446aa --- /dev/null +++ b/docs/source/telegram.messageoriginchat.rst @@ -0,0 +1,6 @@ +MessageOriginChat +================= + +.. autoclass:: telegram.MessageOriginChat + :members: + :show-inheritance: diff --git a/docs/source/telegram.messageoriginhiddenuser.rst b/docs/source/telegram.messageoriginhiddenuser.rst new file mode 100644 index 00000000000..7556a94f00c --- /dev/null +++ b/docs/source/telegram.messageoriginhiddenuser.rst @@ -0,0 +1,7 @@ +MessageOriginHiddenUser +======================= + +.. autoclass:: telegram.MessageOriginHiddenUser + :members: + :show-inheritance: + diff --git a/docs/source/telegram.messageoriginuser.rst b/docs/source/telegram.messageoriginuser.rst new file mode 100644 index 00000000000..365bb455d17 --- /dev/null +++ b/docs/source/telegram.messageoriginuser.rst @@ -0,0 +1,6 @@ +MessageOriginUser +================= + +.. autoclass:: telegram.MessageOriginUser + :members: + :show-inheritance: diff --git a/docs/source/telegram.messagereactioncountupdated.rst b/docs/source/telegram.messagereactioncountupdated.rst new file mode 100644 index 00000000000..4a0aead1e81 --- /dev/null +++ b/docs/source/telegram.messagereactioncountupdated.rst @@ -0,0 +1,6 @@ +MessageReactionCountUpdated +=========================== + +.. autoclass:: telegram.MessageReactionCountUpdated + :members: + :show-inheritance: diff --git a/docs/source/telegram.messagereactionupdated.rst b/docs/source/telegram.messagereactionupdated.rst new file mode 100644 index 00000000000..7110fb23fee --- /dev/null +++ b/docs/source/telegram.messagereactionupdated.rst @@ -0,0 +1,6 @@ +MessageReactionUpdated +====================== + +.. autoclass:: telegram.MessageReactionUpdated + :members: + :show-inheritance: diff --git a/docs/source/telegram.reactioncount.rst b/docs/source/telegram.reactioncount.rst new file mode 100644 index 00000000000..f93a4b760b8 --- /dev/null +++ b/docs/source/telegram.reactioncount.rst @@ -0,0 +1,6 @@ +ReactionCount +============= + +.. autoclass:: telegram.ReactionCount + :members: + :show-inheritance: diff --git a/docs/source/telegram.reactiontype.rst b/docs/source/telegram.reactiontype.rst new file mode 100644 index 00000000000..c726049312a --- /dev/null +++ b/docs/source/telegram.reactiontype.rst @@ -0,0 +1,6 @@ +ReactionType +============ + +.. autoclass:: telegram.ReactionType + :members: + :show-inheritance: diff --git a/docs/source/telegram.reactiontypecustomemoji.rst b/docs/source/telegram.reactiontypecustomemoji.rst new file mode 100644 index 00000000000..e4faf95d9e5 --- /dev/null +++ b/docs/source/telegram.reactiontypecustomemoji.rst @@ -0,0 +1,6 @@ +ReactionTypeCustomEmoji +======================= + +.. autoclass:: telegram.ReactionTypeCustomEmoji + :members: + :show-inheritance: diff --git a/docs/source/telegram.reactiontypeemoji.rst b/docs/source/telegram.reactiontypeemoji.rst new file mode 100644 index 00000000000..cebad3665da --- /dev/null +++ b/docs/source/telegram.reactiontypeemoji.rst @@ -0,0 +1,6 @@ +ReactionTypeEmoji +================= + +.. autoclass:: telegram.ReactionTypeEmoji + :members: + :show-inheritance: diff --git a/docs/source/telegram.replyparameters.rst b/docs/source/telegram.replyparameters.rst new file mode 100644 index 00000000000..efe32e10441 --- /dev/null +++ b/docs/source/telegram.replyparameters.rst @@ -0,0 +1,6 @@ +ReplyParameters +=============== + +.. autoclass:: telegram.ReplyParameters + :members: + :show-inheritance: diff --git a/docs/source/telegram.textquote.rst b/docs/source/telegram.textquote.rst new file mode 100644 index 00000000000..4e11ff74132 --- /dev/null +++ b/docs/source/telegram.textquote.rst @@ -0,0 +1,6 @@ +TextQuote +========= + +.. autoclass:: telegram.TextQuote + :members: + :show-inheritance: diff --git a/docs/source/telegram.userchatboosts.rst b/docs/source/telegram.userchatboosts.rst new file mode 100644 index 00000000000..e5fc6125d98 --- /dev/null +++ b/docs/source/telegram.userchatboosts.rst @@ -0,0 +1,8 @@ +UserChatBoosts +============== + +.. versionadded:: NEXT.VERSION + +.. autoclass:: telegram.UserChatBoosts + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.usershared.rst b/docs/source/telegram.usershared.rst index d42e8b28171..831c1d9492a 100644 --- a/docs/source/telegram.usershared.rst +++ b/docs/source/telegram.usershared.rst @@ -1,5 +1,5 @@ UserShared -=================== +========== .. autoclass:: telegram.UserShared :members: diff --git a/docs/source/telegram.usersshared.rst b/docs/source/telegram.usersshared.rst new file mode 100644 index 00000000000..5af3457f59e --- /dev/null +++ b/docs/source/telegram.usersshared.rst @@ -0,0 +1,6 @@ +UsersShared +=========== + +.. autoclass:: telegram.UsersShared + :members: + :show-inheritance: diff --git a/docs/substitutions/global.rst b/docs/substitutions/global.rst index bc5fc74e130..3909c0d51d9 100644 --- a/docs/substitutions/global.rst +++ b/docs/substitutions/global.rst @@ -62,4 +62,18 @@ .. |removed_thumb_wildcard_note| replace:: Removed the deprecated arguments and attributes ``thumb_*``. -.. |async_context_manager| replace:: Asynchronous context manager which \ No newline at end of file +.. |async_context_manager| replace:: Asynchronous context manager which + +.. |reply_parameters| replace:: Description of the message to reply to. + +.. |rtm_aswr_deprecated| replace:: replacing this argument. PTB will automatically convert this argument to that one, but you should update your code to use the new argument. + +.. |keyword_only_arg| replace:: In future versions, this argument will become a keyword-only argument. + +.. |text_html| replace:: The return value of this property is a best-effort approach. Unfortunately, it can not be guaranteed that sending a message with the returned string will render in the same way as the original message produces the same :attr:`~telegram.Message.entities`/:attr:`~telegram.Message.caption_entities` as the original message. For example, Telegram recommends that entities of type :attr:`~telegram.MessageEntity.BLOCKQUOTE` and :attr:`~telegram.MessageEntity.PRE` *should* start and end on a new line, but does not enforce this and leaves rendering decisions up to the clients. + +.. |text_markdown| replace:: |text_html| Moreover, markdown formatting is inherently less expressive than HTML, so some edge cases may not be coverable at all. For example, markdown formatting can not specify two consecutive block quotes without a blank line in between, but HTML can. + +.. |reply_quote| replace:: If set to :obj:`True`, the reply is sent as an actual reply to this message. If ``reply_to_message_id`` is passed, this parameter will be ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + +.. |do_quote| replace:: If set to :obj:`True`, the replied message is quoted. For a dict, it must be the output of :meth:`~telegram.Message.build_reply_arguments` to specify exact ``reply_parameters``. If ``reply_to_message_id`` or ``reply_parameters`` are passed, this parameter will be ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. diff --git a/telegram/__init__.py b/telegram/__init__.py index 6291edddd40..44505e52732 100644 --- a/telegram/__init__.py +++ b/telegram/__init__.py @@ -19,8 +19,7 @@ """A library that provides a Python interface to the Telegram Bot API""" __author__ = "devs@python-telegram-bot.org" - -__all__ = ( # Keep this alphabetically ordered +__all__ = ( "Animation", "Audio", "Bot", @@ -40,6 +39,13 @@ "CallbackQuery", "Chat", "ChatAdministratorRights", + "ChatBoost", + "ChatBoostRemoved", + "ChatBoostSource", + "ChatBoostSourceGiftCode", + "ChatBoostSourceGiveaway", + "ChatBoostSourcePremium", + "ChatBoostUpdated", "ChatInviteLink", "ChatJoinRequest", "ChatLocation", @@ -62,6 +68,7 @@ "Document", "EncryptedCredentials", "EncryptedPassportElement", + "ExternalReplyInfo", "File", "FileCredentials", "ForceReply", @@ -74,7 +81,12 @@ "GameHighScore", "GeneralForumTopicHidden", "GeneralForumTopicUnhidden", + "Giveaway", + "GiveawayCompleted", + "GiveawayCreated", + "GiveawayWinners", "IdDocumentData", + "InaccessibleMessage", "InlineKeyboardButton", "InlineKeyboardMarkup", "InlineQuery", @@ -119,10 +131,13 @@ "KeyboardButtonPollType", "KeyboardButtonRequestChat", "KeyboardButtonRequestUser", + "KeyboardButtonRequestUsers", "LabeledPrice", + "LinkPreviewOptions", "Location", "LoginUrl", "MaskPosition", + "MaybeInaccessibleMessage", "MenuButton", "MenuButtonCommands", "MenuButtonDefault", @@ -131,6 +146,13 @@ "MessageAutoDeleteTimerChanged", "MessageEntity", "MessageId", + "MessageOrigin", + "MessageOriginChannel", + "MessageOriginChat", + "MessageOriginHiddenUser", + "MessageOriginUser", + "MessageReactionCountUpdated", + "MessageReactionUpdated", "OrderInfo", "PassportData", "PassportElementError", @@ -151,8 +173,13 @@ "PollOption", "PreCheckoutQuery", "ProximityAlertTriggered", + "ReactionCount", + "ReactionType", + "ReactionTypeCustomEmoji", + "ReactionTypeEmoji", "ReplyKeyboardMarkup", "ReplyKeyboardRemove", + "ReplyParameters", "ResidentialAddress", "SecureData", "SecureValue", @@ -166,10 +193,13 @@ "SuccessfulPayment", "SwitchInlineQueryChosenChat", "TelegramObject", + "TextQuote", "Update", "User", + "UserChatBoosts", "UserProfilePhotos", "UserShared", + "UsersShared", "Venue", "Video", "VideoChatEnded", @@ -212,6 +242,16 @@ from ._callbackquery import CallbackQuery from ._chat import Chat from ._chatadministratorrights import ChatAdministratorRights +from ._chatboost import ( + ChatBoost, + ChatBoostRemoved, + ChatBoostSource, + ChatBoostSourceGiftCode, + ChatBoostSourceGiveaway, + ChatBoostSourcePremium, + ChatBoostUpdated, + UserChatBoosts, +) from ._chatinvitelink import ChatInviteLink from ._chatjoinrequest import ChatJoinRequest from ._chatlocation import ChatLocation @@ -264,6 +304,7 @@ from ._games.callbackgame import CallbackGame from ._games.game import Game from ._games.gamehighscore import GameHighScore +from ._giveaway import Giveaway, GiveawayCompleted, GiveawayCreated, GiveawayWinners from ._inline.inlinekeyboardbutton import InlineKeyboardButton from ._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from ._inline.inlinequery import InlineQuery @@ -297,13 +338,26 @@ from ._inline.inputvenuemessagecontent import InputVenueMessageContent from ._keyboardbutton import KeyboardButton from ._keyboardbuttonpolltype import KeyboardButtonPollType -from ._keyboardbuttonrequest import KeyboardButtonRequestChat, KeyboardButtonRequestUser +from ._keyboardbuttonrequest import ( + KeyboardButtonRequestChat, + KeyboardButtonRequestUser, + KeyboardButtonRequestUsers, +) +from ._linkpreviewoptions import LinkPreviewOptions from ._loginurl import LoginUrl from ._menubutton import MenuButton, MenuButtonCommands, MenuButtonDefault, MenuButtonWebApp -from ._message import Message +from ._message import InaccessibleMessage, MaybeInaccessibleMessage, Message from ._messageautodeletetimerchanged import MessageAutoDeleteTimerChanged from ._messageentity import MessageEntity from ._messageid import MessageId +from ._messageorigin import ( + MessageOrigin, + MessageOriginChannel, + MessageOriginChat, + MessageOriginHiddenUser, + MessageOriginUser, +) +from ._messagereactionupdated import MessageReactionCountUpdated, MessageReactionUpdated from ._passport.credentials import ( Credentials, DataCredentials, @@ -338,10 +392,12 @@ from ._payment.successfulpayment import SuccessfulPayment from ._poll import Poll, PollAnswer, PollOption from ._proximityalerttriggered import ProximityAlertTriggered +from ._reaction import ReactionCount, ReactionType, ReactionTypeCustomEmoji, ReactionTypeEmoji +from ._reply import ExternalReplyInfo, ReplyParameters, TextQuote from ._replykeyboardmarkup import ReplyKeyboardMarkup from ._replykeyboardremove import ReplyKeyboardRemove from ._sentwebappmessage import SentWebAppMessage -from ._shared import ChatShared, UserShared +from ._shared import ChatShared, UserShared, UsersShared from ._story import Story from ._switchinlinequerychosenchat import SwitchInlineQueryChosenChat from ._telegramobject import TelegramObject diff --git a/telegram/_bot.py b/telegram/_bot.py index 0c6c8b248bc..0392f318224 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -35,6 +35,7 @@ NoReturn, Optional, Sequence, + Set, Tuple, Type, TypeVar, @@ -59,6 +60,7 @@ from telegram._botname import BotName from telegram._chat import Chat from telegram._chatadministratorrights import ChatAdministratorRights +from telegram._chatboost import UserChatBoosts from telegram._chatinvitelink import ChatInviteLink from telegram._chatmember import ChatMember from telegram._chatpermissions import ChatPermissions @@ -83,6 +85,8 @@ from telegram._message import Message from telegram._messageid import MessageId from telegram._poll import Poll +from telegram._reaction import ReactionType, ReactionTypeCustomEmoji, ReactionTypeEmoji +from telegram._reply import ReplyParameters from telegram._sentwebappmessage import SentWebAppMessage from telegram._telegramobject import TelegramObject from telegram._update import Update @@ -96,8 +100,9 @@ from telegram._utils.strings import to_camel_case from telegram._utils.types import CorrectOptionID, FileInput, JSONDict, ODVInput, ReplyMarkup from telegram._utils.warnings import warn +from telegram._utils.warnings_transition import warn_for_link_preview_options from telegram._webhookinfo import WebhookInfo -from telegram.constants import InlineQueryLimit +from telegram.constants import InlineQueryLimit, ReactionEmoji from telegram.error import EndPointNotFound, InvalidToken from telegram.request import BaseRequest, RequestData from telegram.request._httpxrequest import HTTPXRequest @@ -115,6 +120,7 @@ InputMediaVideo, InputSticker, LabeledPrice, + LinkPreviewOptions, MessageEntity, PassportElementError, ShippingOption, @@ -671,7 +677,8 @@ async def _send_message( caption: Optional[str] = None, parse_mode: ODVInput[str] = DEFAULT_NONE, caption_entities: Optional[Sequence["MessageEntity"]] = None, - disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, + link_preview_options: ODVInput["LinkPreviewOptions"] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -689,15 +696,30 @@ async def _send_message( using `Any` instead saves us a lot of `type: ignore` comments """ # We don't check if (DEFAULT_)None here, so that _post is able to insert the defaults - # correctly, if necessary + # correctly, if necessary: + if allow_sending_without_reply is not DEFAULT_NONE and reply_parameters is not None: + raise ValueError( + "`allow_sending_without_reply` and `reply_parameters` are mutually exclusive." + ) + + if reply_to_message_id is not None and reply_parameters is not None: + raise ValueError( + "`reply_to_message_id` and `reply_parameters` are mutually exclusive." + ) + + if reply_to_message_id is not None: + reply_parameters = ReplyParameters( + message_id=reply_to_message_id, + allow_sending_without_reply=allow_sending_without_reply, + ) + data["disable_notification"] = disable_notification - data["allow_sending_without_reply"] = allow_sending_without_reply data["protect_content"] = protect_content data["parse_mode"] = parse_mode - data["disable_web_page_preview"] = disable_web_page_preview + data["reply_parameters"] = reply_parameters - if reply_to_message_id is not None: - data["reply_to_message_id"] = reply_to_message_id + if link_preview_options is not None: + data["link_preview_options"] = link_preview_options if reply_markup is not None: data["reply_markup"] = reply_markup @@ -896,13 +918,18 @@ async def send_message( text: str, parse_mode: ODVInput[str] = DEFAULT_NONE, entities: Optional[Sequence["MessageEntity"]] = None, + # Deprecated since Bot API 7.0 (to be made keyword arg): + # --- disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, + # --- disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, reply_markup: Optional[ReplyMarkup] = None, message_thread_id: Optional[int] = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -924,14 +951,45 @@ async def send_message( .. versionchanged:: 20.0 |sequenceargs| + link_preview_options (:obj:`LinkPreviewOptions`, optional): Link preview generation + options for the message. Mutually exclusive with + :paramref:`disable_web_page_preview`. + + .. versionadded:: NEXT.VERSION + disable_web_page_preview (:obj:`bool`, optional): Disables link previews for links in - this message. + this message. Mutually exclusive with :paramref:`link_preview_options`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`link_preview_options` replacing this + argument. PTB will automatically convert this argument to that one, but + for advanced options, please use :paramref:`link_preview_options` directly. + + .. deprecated:: NEXT.VERSION + In future versions, this argument will become a keyword-only argument. + disable_notification (:obj:`bool`, optional): |disable_notification| protect_content (:obj:`bool`, optional): |protect_content| .. versionadded:: 13.10 reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + reply_markup (:class:`InlineKeyboardMarkup` | :class:`ReplyKeyboardMarkup` | \ :class:`ReplyKeyboardRemove` | :class:`ForceReply`, optional): Additional interface options. An object for an inline keyboard, custom reply @@ -939,15 +997,23 @@ async def send_message( message_thread_id (:obj:`int`, optional): |message_thread_id_arg| .. versionadded:: 20.0 + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, the sent message is returned. Raises: - :class:`telegram.error.TelegramError` + :exc:`ValueError`: If both :paramref:`disable_web_page_preview` and + :paramref:`link_preview_options` are passed. + :class:`telegram.error.TelegramError`: For other errors. """ data: JSONDict = {"chat_id": chat_id, "text": text, "entities": entities} + link_preview_options = warn_for_link_preview_options( + disable_web_page_preview, link_preview_options + ) return await self._send_message( "sendMessage", @@ -959,7 +1025,8 @@ async def send_message( protect_content=protect_content, message_thread_id=message_thread_id, parse_mode=parse_mode, - disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -1026,6 +1093,48 @@ async def delete_message( api_kwargs=api_kwargs, ) + @_log + async def delete_messages( + self, + chat_id: Union[int, str], + message_ids: Sequence[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: Optional[JSONDict] = None, + ) -> bool: + """ + Use this method to delete multiple messages simultaneously. If some of the specified + messages can't be found, they are skipped. + + .. versionadded:: NEXT.VERSION + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + message_ids (Sequence[:obj:`int`]): Identifiers of + :tg-const:`telegram.constants.BulkRequestLimit.MIN_LIMIT`- + :tg-const:`telegram.constants.BulkRequestLimit.MAX_LIMIT` messages to delete. + See :meth:`delete_message` for limitations on which messages can be deleted. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = {"chat_id": chat_id, "message_ids": message_ids} + return await self._post( + "deleteMessages", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + @_log async def forward_message( self, @@ -1092,6 +1201,69 @@ async def forward_message( api_kwargs=api_kwargs, ) + @_log + async def forward_messages( + self, + chat_id: Union[int, str], + from_chat_id: Union[str, int], + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: Optional[int] = 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: Optional[JSONDict] = None, + ) -> Tuple[MessageId, ...]: + """ + Use this method to forward messages of any kind. If some of the specified messages can't be + found or forwarded, they are skipped. Service messages and messages with protected content + can't be forwarded. Album grouping is kept for forwarded messages. + + .. versionadded:: NEXT.VERSION + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + from_chat_id (:obj:`int` | :obj:`str`): Unique identifier for the chat where the + original message was sent (or channel username in the format ``@channelusername``). + message_ids (Sequence[:obj:`int`]): Identifiers of + :tg-const:`telegram.constants.BulkRequestLimit.MIN_LIMIT`- + :tg-const:`telegram.constants.BulkRequestLimit.MAX_LIMIT` messages in the chat + :paramref:`from_chat_id` to forward. The identifiers must be specified in a + strictly increasing order. + disable_notification (:obj:`bool`, optional): |disable_notification| + protect_content (:obj:`bool`, optional): |protect_content| + message_thread_id (:obj:`int`, optional): |message_thread_id_arg| + + Returns: + Tuple[:class:`telegram.Message`]: On success, a tuple of ``MessageId`` of sent messages + is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "chat_id": chat_id, + "from_chat_id": from_chat_id, + "message_ids": message_ids, + "disable_notification": disable_notification, + "protect_content": protect_content, + "message_thread_id": message_thread_id, + } + + result = await self._post( + "forwardMessages", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + return MessageId.de_list(result, self) + @_log async def send_photo( self, @@ -1107,6 +1279,7 @@ async def send_photo( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, has_spoiler: Optional[bool] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1154,7 +1327,23 @@ async def send_photo( .. versionadded:: 20.0 reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + reply_markup (:class:`InlineKeyboardMarkup` | :class:`ReplyKeyboardMarkup` | \ :class:`ReplyKeyboardRemove` | :class:`ForceReply`, optional): Additional interface options. An object for an inline keyboard, custom reply @@ -1163,6 +1352,9 @@ async def send_photo( with a spoiler animation. .. versionadded:: 20.0 + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: NEXT.VERSION Keyword Args: filename (:obj:`str`, optional): Custom file name for the photo, when uploading a @@ -1196,6 +1388,7 @@ async def send_photo( 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, @@ -1221,6 +1414,7 @@ async def send_audio( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1276,9 +1470,24 @@ async def send_audio( message_thread_id (:obj:`int`, optional): |message_thread_id_arg| .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + reply_markup (:class:`InlineKeyboardMarkup` | :class:`ReplyKeyboardMarkup` | \ :class:`ReplyKeyboardRemove` | :class:`ForceReply`, optional): Additional interface options. An object for an inline keyboard, custom reply @@ -1287,6 +1496,9 @@ async def send_audio( optional): |thumbdocstring| .. versionadded:: 20.2 + reply_parameters (:obj:`ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: NEXT.VERSION Keyword Args: filename (:obj:`str`, optional): Custom file name for the audio, when uploading a @@ -1323,6 +1535,7 @@ async def send_audio( 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, @@ -1346,6 +1559,7 @@ async def send_document( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1400,9 +1614,24 @@ async def send_document( message_thread_id (:obj:`int`, optional): |message_thread_id_arg| .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + reply_markup (:class:`InlineKeyboardMarkup` | :class:`ReplyKeyboardMarkup` | \ :class:`ReplyKeyboardRemove` | :class:`ForceReply`, optional): Additional interface options. An object for an inline keyboard, custom reply @@ -1411,6 +1640,9 @@ async def send_document( optional): |thumbdocstring| .. versionadded:: 20.2 + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: NEXT.VERSION Keyword Args: filename (:obj:`str`, optional): Custom file name for the document, when uploading a @@ -1443,6 +1675,7 @@ async def send_document( 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, @@ -1462,6 +1695,7 @@ async def send_sticker( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, emoji: Optional[str] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1500,13 +1734,31 @@ async def send_sticker( message_thread_id (:obj:`int`, optional): |message_thread_id_arg| .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + 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. + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, the sent Message is returned. @@ -1529,6 +1781,7 @@ async def send_sticker( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -1556,6 +1809,7 @@ async def send_video( message_thread_id: Optional[int] = None, has_spoiler: Optional[bool] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1617,7 +1871,23 @@ async def send_video( .. versionadded:: 20.0 reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + reply_markup (:class:`InlineKeyboardMarkup` | :class:`ReplyKeyboardMarkup` | \ :class:`ReplyKeyboardRemove` | :class:`ForceReply`, optional): Additional interface options. An object for an inline keyboard, custom reply @@ -1630,6 +1900,9 @@ async def send_video( optional): |thumbdocstring| .. versionadded:: 20.2 + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: NEXT.VERSION Keyword Args: filename (:obj:`str`, optional): Custom file name for the video, when uploading a @@ -1668,6 +1941,7 @@ async def send_video( 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, @@ -1689,6 +1963,7 @@ async def send_video_note( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1737,9 +2012,24 @@ async def send_video_note( message_thread_id (:obj:`int`, optional): |message_thread_id_arg| .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + reply_markup (:class:`InlineKeyboardMarkup` | :class:`ReplyKeyboardMarkup` | \ :class:`ReplyKeyboardRemove` | :class:`ForceReply`, optional): Additional interface options. An object for an inline keyboard, custom reply @@ -1748,6 +2038,9 @@ async def send_video_note( optional): |thumbdocstring| .. versionadded:: 20.2 + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: NEXT.VERSION Keyword Args: filename (:obj:`str`, optional): Custom file name for the video note, when uploading a @@ -1780,6 +2073,7 @@ async def send_video_note( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -1806,6 +2100,7 @@ async def send_animation( message_thread_id: Optional[int] = None, has_spoiler: Optional[bool] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1861,7 +2156,23 @@ async def send_animation( .. versionadded:: 20.0 reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + reply_markup (:class:`InlineKeyboardMarkup` | :class:`ReplyKeyboardMarkup` | \ :class:`ReplyKeyboardRemove` | :class:`ForceReply`, optional): Additional interface options. An object for an inline keyboard, custom reply @@ -1874,6 +2185,9 @@ async def send_animation( optional): |thumbdocstring| .. versionadded:: 20.2 + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: NEXT.VERSION Keyword Args: filename (:obj:`str`, optional): Custom file name for the animation, when uploading a @@ -1911,6 +2225,7 @@ async def send_animation( 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, @@ -1933,6 +2248,7 @@ async def send_voice( caption_entities: Optional[Sequence["MessageEntity"]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1987,13 +2303,31 @@ async def send_voice( message_thread_id (:obj:`int`, optional): |message_thread_id_arg| .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + 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. + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: NEXT.VERSION Keyword Args: filename (:obj:`str`, optional): Custom file name for the voice, when uploading a @@ -2027,6 +2361,7 @@ async def send_voice( 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, @@ -2046,6 +2381,7 @@ async def send_media_group( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2087,9 +2423,27 @@ async def send_media_group( message_thread_id (:obj:`int`, optional): |message_thread_id_arg| .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: NEXT.VERSION Keyword Args: caption (:obj:`str`, optional): Caption that will be added to the @@ -2141,14 +2495,29 @@ async def send_media_group( media = list(media) media[0] = item_to_get_caption + if allow_sending_without_reply is not DEFAULT_NONE and reply_parameters is not None: + raise ValueError( + "`allow_sending_without_reply` and `reply_parameters` are mutually exclusive." + ) + + if reply_to_message_id is not None and reply_parameters is not None: + raise ValueError( + "`reply_to_message_id` and `reply_parameters` are mutually exclusive." + ) + + if reply_to_message_id is not None: + reply_parameters = ReplyParameters( + message_id=reply_to_message_id, + allow_sending_without_reply=allow_sending_without_reply, + ) + data: JSONDict = { "chat_id": chat_id, "media": media, "disable_notification": disable_notification, - "allow_sending_without_reply": allow_sending_without_reply, "protect_content": protect_content, "message_thread_id": message_thread_id, - "reply_to_message_id": reply_to_message_id, + "reply_parameters": reply_parameters, } result = await self._post( @@ -2179,6 +2548,7 @@ async def send_location( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, location: Optional[Location] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2220,13 +2590,31 @@ async def send_location( message_thread_id (:obj:`int`, optional): |message_thread_id_arg| .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + 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. + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: NEXT.VERSION Keyword Args: location (:class:`telegram.Location`, optional): The location to send. @@ -2271,6 +2659,7 @@ async def send_location( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -2439,6 +2828,7 @@ async def send_venue( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, venue: Optional[Venue] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2478,13 +2868,31 @@ async def send_venue( message_thread_id (:obj:`int`, optional): |message_thread_id_arg| .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + 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. + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: NEXT.VERSION Keyword Args: venue (:class:`telegram.Venue`, optional): The venue to send. @@ -2540,6 +2948,7 @@ async def send_venue( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -2561,6 +2970,7 @@ async def send_contact( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, contact: Optional[Contact] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2590,13 +3000,31 @@ async def send_contact( message_thread_id (:obj:`int`, optional): |message_thread_id_arg| .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + 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. + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: NEXT.VERSION Keyword Args: contact (:class:`telegram.Contact`, optional): The contact to send. @@ -2643,6 +3071,7 @@ async def send_contact( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -2661,6 +3090,7 @@ async def send_game( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2681,12 +3111,29 @@ async def send_game( message_thread_id (:obj:`int`, optional): |message_thread_id_arg| .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): An object for a new inline keyboard. If empty, one "Play game_title" button will be shown. If not empty, the first button must launch the game. + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, the sent Message is returned. @@ -2706,6 +3153,7 @@ async def send_game( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -2844,15 +3292,15 @@ def _insert_defaults_for_ilq_results(self, res: "InlineQueryResult") -> "InlineQ res.input_message_content.parse_mode = DefaultValue.get_value( res.input_message_content.parse_mode ) - if hasattr(res.input_message_content, "disable_web_page_preview"): + if hasattr(res.input_message_content, "link_preview_options"): if not copied: res = copy.copy(res) with res._unfrozen(): res.input_message_content = copy.copy(res.input_message_content) with res.input_message_content._unfrozen(): - res.input_message_content.disable_web_page_preview = DefaultValue.get_value( - res.input_message_content.disable_web_page_preview + res.input_message_content.link_preview_options = DefaultValue.get_value( + res.input_message_content.link_preview_options ) return res @@ -3334,9 +3782,13 @@ async def edit_message_text( message_id: Optional[int] = None, inline_message_id: Optional[str] = None, parse_mode: ODVInput[str] = DEFAULT_NONE, + # Deprecated since Bot API 7.0 (to be keyword only): + # --- disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, + # --- reply_markup: Optional["InlineKeyboardMarkup"] = None, entities: Optional[Sequence["MessageEntity"]] = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -3370,8 +3822,24 @@ async def edit_message_text( .. versionchanged:: 20.0 |sequenceargs| + + link_preview_options (:obj:`LinkPreviewOptions`, optional): Link preview generation + options for the message. Mutually exclusive with + :paramref:`disable_web_page_preview`. + + .. versionadded:: NEXT.VERSION + disable_web_page_preview (:obj:`bool`, optional): Disables link previews for links in - this message. + this message. Mutually exclusive with :paramref:`link_preview_options`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`link_preview_options` replacing this + argument. PTB will automatically convert this argument to that one, but + for advanced options, please use :paramref:`link_preview_options` directly. + + .. deprecated:: NEXT.VERSION + In future versions, this argument will become keyword only. + reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): An object for an inline keyboard. @@ -3380,7 +3848,9 @@ async def edit_message_text( edited message is returned, otherwise :obj:`True` is returned. Raises: - :class:`telegram.error.TelegramError` + :exc:`ValueError`: If both :paramref:`disable_web_page_preview` and + :paramref:`link_preview_options` are passed. + :class:`telegram.error.TelegramError`: For other errors. """ data: JSONDict = { @@ -3391,12 +3861,16 @@ async def edit_message_text( "entities": entities, } + link_preview_options = warn_for_link_preview_options( + disable_web_page_preview, link_preview_options + ) + return await self._send_message( "editMessageText", data, reply_markup=reply_markup, parse_mode=parse_mode, - disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -4313,6 +4787,7 @@ async def send_invoice( suggested_tip_amounts: Optional[Sequence[int]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -4409,12 +4884,30 @@ async def send_invoice( message_thread_id (:obj:`int`, optional): |message_thread_id_arg| .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): An object for an inline keyboard. If empty, one 'Pay total price' button will be shown. If not empty, the first button must be a Pay button. + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, the sent Message is returned. @@ -4457,6 +4950,7 @@ async def send_invoice( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -6310,6 +6804,7 @@ async def send_poll( explanation_entities: Optional[Sequence["MessageEntity"]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -6375,13 +6870,30 @@ async def send_poll( message_thread_id (:obj:`int`, optional): |message_thread_id_arg| .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + 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. + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, the sent Message is returned. @@ -6415,6 +6927,7 @@ async def send_poll( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -6479,6 +6992,7 @@ async def send_dice( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -6493,6 +7007,14 @@ async def send_dice( chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| disable_notification (:obj:`bool`, optional): |disable_notification| reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + reply_markup (:class:`InlineKeyboardMarkup` | :class:`ReplyKeyboardMarkup` | \ :class:`ReplyKeyboardRemove` | :class:`ForceReply`, optional): Additional interface options. An object for an inline keyboard, custom reply @@ -6513,12 +7035,23 @@ async def send_dice( .. versionchanged:: 13.4 Added the :tg-const:`telegram.Dice.BOWLING` emoji. allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + protect_content (:obj:`bool`, optional): |protect_content| .. versionadded:: 13.10 message_thread_id (:obj:`int`, optional): |message_thread_id_arg| .. versionadded:: 20.0 + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, the sent Message is returned. @@ -6538,6 +7071,7 @@ async def send_dice( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -6880,6 +7414,7 @@ async def copy_message( reply_markup: Optional[ReplyMarkup] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -6887,10 +7422,9 @@ async def copy_message( pool_timeout: ODVInput[float] = DEFAULT_NONE, api_kwargs: Optional[JSONDict] = None, ) -> MessageId: - """ - Use this method to copy messages of any kind. Service messages and invoice messages can't - be copied. The method is analogous to the method :meth:`forward_message`, but the copied - message doesn't have a link to the original message. + """Use this method to copy messages of any kind. Service messages and invoice messages + can't be copied. The method is analogous to the method :meth:`forward_message`, but the + copied message doesn't have a link to the original message. Args: chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| @@ -6916,11 +7450,30 @@ async def copy_message( .. versionadded:: 20.0 reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. deprecated:: NEXT.VERSION + |keyword_only_arg| + 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. + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.MessageId`: On success @@ -6929,19 +7482,34 @@ async def copy_message( :class:`telegram.error.TelegramError` """ + if allow_sending_without_reply is not DEFAULT_NONE and reply_parameters is not None: + raise ValueError( + "`allow_sending_without_reply` and `reply_parameters` are mutually exclusive." + ) + + if reply_to_message_id is not None and reply_parameters is not None: + raise ValueError( + "`reply_to_message_id` and `reply_parameters` are mutually exclusive." + ) + + if reply_to_message_id is not None: + reply_parameters = ReplyParameters( + message_id=reply_to_message_id, + allow_sending_without_reply=allow_sending_without_reply, + ) + data: JSONDict = { "chat_id": chat_id, "from_chat_id": from_chat_id, "message_id": message_id, "parse_mode": parse_mode, "disable_notification": disable_notification, - "allow_sending_without_reply": allow_sending_without_reply, "protect_content": protect_content, "caption": caption, "caption_entities": caption_entities, - "reply_to_message_id": reply_to_message_id, "reply_markup": reply_markup, "message_thread_id": message_thread_id, + "reply_parameters": reply_parameters, } result = await self._post( @@ -6955,6 +7523,77 @@ async def copy_message( ) return MessageId.de_json(result, self) # type: ignore[return-value] + @_log + async def copy_messages( + self, + chat_id: Union[int, str], + from_chat_id: Union[str, int], + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: Optional[int] = None, + remove_caption: Optional[bool] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + ) -> Tuple["MessageId", ...]: + """ + Use this method to copy messages of any kind. If some of the specified messages can't be + found or copied, they are skipped. Service messages, giveaway messages, giveaway winners + messages, and invoice messages can't be copied. A quiz poll can be copied only if the value + of the field correct_option_id is known to the bot. The method is analogous to the method + :meth:`forward_messages`, but the copied messages don't have a link to the original + message. Album grouping is kept for copied messages. + + .. versionadded:: NEXT.VERSION + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + from_chat_id (:obj:`int` | :obj:`str`): Unique identifier for the chat where the + original message was sent (or channel username in the format ``@channelusername``). + message_ids (Sequence[:obj:`int`]): Identifiers of + :tg-const:`telegram.constants.BulkRequestLimit.MIN_LIMIT` - + :tg-const:`telegram.constants.BulkRequestLimit.MAX_LIMIT` messages in the chat. + :paramref:`from_chat_id` to copy. The identifiers must be specified in a strictly + increasing order. + disable_notification (:obj:`bool`, optional): |disable_notification| + protect_content (:obj:`bool`, optional): |protect_content| + message_thread_id (:obj:`int`, optional): |message_thread_id_arg| + remove_caption (:obj:`bool`, optional): Pass :obj:`True` to copy the messages without + their captions. + + Returns: + Tuple[:class:`telegram.MessageId`]: On success, a tuple of :class:`~telegram.MessageId` + of the sent messages is returned. + + Raises: + :class:`telegram.error.TelegramError` + + """ + data: JSONDict = { + "chat_id": chat_id, + "from_chat_id": from_chat_id, + "message_ids": message_ids, + "disable_notification": disable_notification, + "protect_content": protect_content, + "message_thread_id": message_thread_id, + "remove_caption": remove_caption, + } + + result = await self._post( + "copyMessages", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + return MessageId.de_list(result, self) + @_log async def set_chat_menu_button( self, @@ -7999,6 +8638,134 @@ async def get_my_name( bot=self, ) + @_log + async def get_user_chat_boosts( + self, + chat_id: Union[str, int], + 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: Optional[JSONDict] = None, + ) -> UserChatBoosts: + """ + Use this method to get the list of boosts added to a chat by a user. Requires + administrator rights in the chat. + + .. versionadded:: NEXT.VERSION + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + user_id (:obj:`int`): Unique identifier of the target user. + + Returns: + :class:`telegram.UserChatBoosts`: On success, the object containing the list of boosts + is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = {"chat_id": chat_id, "user_id": user_id} + return UserChatBoosts.de_json( # type: ignore[return-value] + await self._post( + "getUserChatBoosts", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ), + bot=self, + ) + + @_log + async def set_message_reaction( + self, + chat_id: Union[str, int], + message_id: int, + reaction: Optional[Union[Sequence[Union[ReactionType, str]], ReactionType, str]] = None, + is_big: Optional[bool] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + ) -> bool: + """ + Use this method to change the chosen reactions on a message. Service messages can't be + reacted to. Automatically forwarded messages from a channel to its discussion group have + the same available reactions as messages in the channel. + + .. versionadded:: NEXT.VERSION + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + message_id (:obj:`int`): Identifier of the target message. If the message belongs to a + media group, the reaction is set to the first non-deleted message in the group + instead. + reaction (Sequence[:class:`telegram.ReactionType` | :obj:`str`] | \ + :class:`telegram.ReactionType` | :obj:`str`, optional): New list of reaction + types to set on the message. Currently, as non-premium users, bots can set up to + one reaction per message. A custom emoji reaction can be used if it is either + already present on the message or explicitly allowed by chat administrators. + + Tip: + Passed :obj:`str` values will be converted to either + :class:`telegram.ReactionTypeEmoji` or + :class:`telegram.ReactionTypeCustomEmoji` + depending on whether they are listed in + :class:`~telegram.constants.ReactionEmoji`. + + is_big (:obj:`bool`, optional): Pass :obj:`True` to set the reaction with a big + animation. + + Returns: + :obj:`bool` On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + allowed_reactions: Set[str] = set(ReactionEmoji) + parsed_reaction = ( + [ + ( + entry + if isinstance(entry, ReactionType) + else ( + ReactionTypeEmoji(emoji=entry) + if entry in allowed_reactions + else ReactionTypeCustomEmoji(custom_emoji_id=entry) + ) + ) + for entry in ( + [reaction] if isinstance(reaction, (ReactionType, str)) else reaction + ) + ] + if reaction is not None + else None + ) + + data: JSONDict = { + "chat_id": chat_id, + "message_id": message_id, + "reaction": parsed_reaction, + "is_big": is_big, + } + + return await self._post( + "setMessageReaction", + 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: """See :meth:`telegram.TelegramObject.to_dict`.""" data: JSONDict = {"id": self.id, "username": self.username, "first_name": self.first_name} @@ -8015,8 +8782,12 @@ def to_dict(self, recursive: bool = True) -> JSONDict: """Alias for :meth:`send_message`""" deleteMessage = delete_message """Alias for :meth:`delete_message`""" + deleteMessages = delete_messages + """Alias for :meth:`delete_messages`""" forwardMessage = forward_message """Alias for :meth:`forward_message`""" + forwardMessages = forward_messages + """Alias for :meth:`forward_messages`""" sendPhoto = send_photo """Alias for :meth:`send_photo`""" sendAudio = send_audio @@ -8175,6 +8946,8 @@ def to_dict(self, recursive: bool = True) -> JSONDict: """Alias for :meth:`log_out`""" copyMessage = copy_message """Alias for :meth:`copy_message`""" + copyMessages = copy_messages + """Alias for :meth:`copy_messages`""" getChatMenuButton = get_chat_menu_button """Alias for :meth:`get_chat_menu_button`""" setChatMenuButton = set_chat_menu_button @@ -8235,3 +9008,7 @@ def to_dict(self, recursive: bool = True) -> JSONDict: """Alias for :meth:`get_my_name`""" unpinAllGeneralForumTopicMessages = unpin_all_general_forum_topic_messages """Alias for :meth:`unpin_all_general_forum_topic_messages`""" + getUserChatBoosts = get_user_chat_boosts + """Alias for :meth:`get_user_chat_boosts`""" + setMessageReaction = set_message_reaction + """Alias for :meth:`set_message_reaction`""" diff --git a/telegram/_callbackquery.py b/telegram/_callbackquery.py index 583e2a1771a..c69fbf50240 100644 --- a/telegram/_callbackquery.py +++ b/telegram/_callbackquery.py @@ -22,7 +22,7 @@ from telegram import constants from telegram._files.location import Location -from telegram._message import Message +from telegram._message import MaybeInaccessibleMessage, Message from telegram._telegramobject import TelegramObject from telegram._user import User from telegram._utils.defaultvalue import DEFAULT_NONE @@ -34,8 +34,10 @@ GameHighScore, InlineKeyboardMarkup, InputMedia, + LinkPreviewOptions, MessageEntity, MessageId, + ReplyParameters, ) @@ -70,9 +72,11 @@ class CallbackQuery(TelegramObject): from_user (:class:`telegram.User`): Sender. chat_instance (:obj:`str`): Global identifier, uniquely corresponding to the chat to which the message with the callback button was sent. Useful for high scores in games. - message (:class:`telegram.Message`, optional): Message with the callback button that - originated the query. Note that message content and message date will not be available - if the message is too old. + message (:class:`telegram.MaybeInaccessibleMessage`, optional): Message sent by the bot + with the callback button that originated the query. + + .. versionchanged:: NEXT.VERSION + Accept objects of type :class:`telegram.MaybeInaccessibleMessage` since Bot API 7.0. data (:obj:`str`, optional): Data associated with the callback button. Be aware that the message, which originated the query, can contain no callback buttons with this data. inline_message_id (:obj:`str`, optional): Identifier of the message sent via the bot in @@ -85,9 +89,12 @@ class CallbackQuery(TelegramObject): from_user (:class:`telegram.User`): Sender. chat_instance (:obj:`str`): Global identifier, uniquely corresponding to the chat to which the message with the callback button was sent. Useful for high scores in games. - message (:class:`telegram.Message`): Optional. Message with the callback button that - originated the query. Note that message content and message date will not be available - if the message is too old. + message (:class:`telegram.MaybeInaccessibleMessage`): Optional. Message sent by the bot + with the callback button that originated the query. + + .. versionchanged:: NEXT.VERSION + Objects maybe be of type :class:`telegram.MaybeInaccessibleMessage` since Bot API + 7.0. data (:obj:`str` | :obj:`object`): Optional. Data associated with the callback button. Be aware that the message, which originated the query, can contain no callback buttons with this data. @@ -118,7 +125,7 @@ def __init__( id: str, from_user: User, chat_instance: str, - message: Optional[Message] = None, + message: Optional[MaybeInaccessibleMessage] = None, data: Optional[str] = None, inline_message_id: Optional[str] = None, game_short_name: Optional[str] = None, @@ -131,7 +138,7 @@ def __init__( self.from_user: User = from_user self.chat_instance: str = chat_instance # Optionals - self.message: Optional[Message] = message + self.message: Optional[MaybeInaccessibleMessage] = message self.data: Optional[str] = data self.inline_message_id: Optional[str] = inline_message_id self.game_short_name: Optional[str] = game_short_name @@ -190,6 +197,14 @@ async def answer( api_kwargs=api_kwargs, ) + def _get_message(self, action: str = "edit") -> Message: + """Helper method to get the message for the shortcut methods. Must be called only + if :attr:`inline_message_id` is *not* set. + """ + if not isinstance(self.message, Message): + raise TypeError(f"Cannot {action} an inaccessible message") + return self.message + async def edit_message_text( self, text: str, @@ -197,6 +212,7 @@ async def edit_message_text( disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, reply_markup: Optional["InlineKeyboardMarkup"] = None, entities: Optional[Sequence["MessageEntity"]] = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -217,10 +233,16 @@ async def edit_message_text( For the documentation of the arguments, please see :meth:`telegram.Bot.edit_message_text` and :meth:`telegram.Message.edit_text`. + .. versionchanged:: NEXT.VERSION + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :class:`telegram.Message`: On success, if edited message is sent by the bot, the edited Message is returned, otherwise :obj:`True` is returned. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. + """ if self.inline_message_id: return await self.get_bot().edit_message_text( @@ -228,6 +250,7 @@ async def edit_message_text( text=text, parse_mode=parse_mode, disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -238,10 +261,11 @@ async def edit_message_text( chat_id=None, message_id=None, ) - return await self.message.edit_text( + return await self._get_message().edit_text( text=text, parse_mode=parse_mode, disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -277,10 +301,16 @@ async def edit_message_caption( For the documentation of the arguments, please see :meth:`telegram.Bot.edit_message_caption` and :meth:`telegram.Message.edit_caption`. + .. versionchanged:: NEXT.VERSION + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :class:`telegram.Message`: On success, if edited message is sent by the bot, the edited Message is returned, otherwise :obj:`True` is returned. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. + """ if self.inline_message_id: return await self.get_bot().edit_message_caption( @@ -297,7 +327,7 @@ async def edit_message_caption( chat_id=None, message_id=None, ) - return await self.message.edit_caption( + return await self._get_message().edit_caption( caption=caption, reply_markup=reply_markup, read_timeout=read_timeout, @@ -333,10 +363,16 @@ async def edit_message_reply_markup( :meth:`telegram.Bot.edit_message_reply_markup` and :meth:`telegram.Message.edit_reply_markup`. + .. versionchanged:: NEXT.VERSION + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :class:`telegram.Message`: On success, if edited message is sent by the bot, the edited Message is returned, otherwise :obj:`True` is returned. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. + """ if self.inline_message_id: return await self.get_bot().edit_message_reply_markup( @@ -350,7 +386,7 @@ async def edit_message_reply_markup( chat_id=None, message_id=None, ) - return await self.message.edit_reply_markup( + return await self._get_message().edit_reply_markup( reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -383,10 +419,16 @@ async def edit_message_media( For the documentation of the arguments, please see :meth:`telegram.Bot.edit_message_media` and :meth:`telegram.Message.edit_media`. + .. versionchanged:: NEXT.VERSION + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :class:`telegram.Message`: On success, if edited message is not an inline message, the edited Message is returned, otherwise :obj:`True` is returned. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. + """ if self.inline_message_id: return await self.get_bot().edit_message_media( @@ -401,7 +443,7 @@ async def edit_message_media( chat_id=None, message_id=None, ) - return await self.message.edit_media( + return await self._get_message().edit_media( media=media, reply_markup=reply_markup, read_timeout=read_timeout, @@ -441,10 +483,16 @@ async def edit_message_live_location( :meth:`telegram.Bot.edit_message_live_location` and :meth:`telegram.Message.edit_live_location`. + .. versionchanged:: NEXT.VERSION + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :class:`telegram.Message`: On success, if edited message is sent by the bot, the edited Message is returned, otherwise :obj:`True` is returned. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. + """ if self.inline_message_id: return await self.get_bot().edit_message_live_location( @@ -464,7 +512,7 @@ async def edit_message_live_location( chat_id=None, message_id=None, ) - return await self.message.edit_live_location( + return await self._get_message().edit_live_location( latitude=latitude, longitude=longitude, location=location, @@ -503,10 +551,16 @@ async def stop_message_live_location( :meth:`telegram.Bot.stop_message_live_location` and :meth:`telegram.Message.stop_live_location`. + .. versionchanged:: NEXT.VERSION + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :class:`telegram.Message`: On success, if edited message is sent by the bot, the edited Message is returned, otherwise :obj:`True` is returned. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. + """ if self.inline_message_id: return await self.get_bot().stop_message_live_location( @@ -520,7 +574,7 @@ async def stop_message_live_location( chat_id=None, message_id=None, ) - return await self.message.stop_live_location( + return await self._get_message().stop_live_location( reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -555,10 +609,16 @@ async def set_game_score( For the documentation of the arguments, please see :meth:`telegram.Bot.set_game_score` and :meth:`telegram.Message.set_game_score`. + .. versionchanged:: NEXT.VERSION + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :class:`telegram.Message`: On success, if edited message is sent by the bot, the edited Message is returned, otherwise :obj:`True` is returned. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. + """ if self.inline_message_id: return await self.get_bot().set_game_score( @@ -575,7 +635,7 @@ async def set_game_score( chat_id=None, message_id=None, ) - return await self.message.set_game_score( + return await self._get_message().set_game_score( user_id=user_id, score=score, force=force, @@ -611,9 +671,15 @@ async def get_game_high_scores( :meth:`telegram.Bot.get_game_high_scores` and :meth:`telegram.Message.get_game_high_scores`. + .. versionchanged:: NEXT.VERSION + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: Tuple[:class:`telegram.GameHighScore`] + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. + """ if self.inline_message_id: return await self.get_bot().get_game_high_scores( @@ -627,7 +693,7 @@ async def get_game_high_scores( chat_id=None, message_id=None, ) - return await self.message.get_game_high_scores( + return await self._get_message().get_game_high_scores( user_id=user_id, read_timeout=read_timeout, write_timeout=write_timeout, @@ -651,11 +717,17 @@ async def delete_message( For the documentation of the arguments, please see :meth:`telegram.Message.delete`. + .. versionchanged:: NEXT.VERSION + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :obj:`bool`: On success, :obj:`True` is returned. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. + """ - return await self.message.delete( + return await self._get_message(action="delete").delete( read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -679,11 +751,16 @@ async def pin_message( For the documentation of the arguments, please see :meth:`telegram.Message.pin`. + .. versionchanged:: NEXT.VERSION + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :obj:`bool`: On success, :obj:`True` is returned. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. """ - return await self.message.pin( + return await self._get_message(action="pin").pin( disable_notification=disable_notification, read_timeout=read_timeout, write_timeout=write_timeout, @@ -707,11 +784,16 @@ async def unpin_message( For the documentation of the arguments, please see :meth:`telegram.Message.unpin`. + .. versionchanged:: NEXT.VERSION + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :obj:`bool`: On success, :obj:`True` is returned. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. """ - return await self.message.unpin( + return await self._get_message(action="unpin").unpin( read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -731,6 +813,7 @@ async def copy_message( reply_markup: Optional[ReplyMarkup] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -749,11 +832,16 @@ async def copy_message( For the documentation of the arguments, please see :meth:`telegram.Message.copy`. + .. versionchanged:: NEXT.VERSION + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :class:`telegram.MessageId`: On success, returns the MessageId of the sent message. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. """ - return await self.message.copy( + return await self._get_message(action="copy").copy( chat_id=chat_id, caption=caption, parse_mode=parse_mode, @@ -769,6 +857,7 @@ async def copy_message( api_kwargs=api_kwargs, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, ) MAX_ANSWER_TEXT_LENGTH: Final[int] = ( diff --git a/telegram/_chat.py b/telegram/_chat.py index 615c7bfe9a0..018be237e0a 100644 --- a/telegram/_chat.py +++ b/telegram/_chat.py @@ -28,6 +28,7 @@ from telegram._files.chatphoto import ChatPhoto from telegram._forumtopic import ForumTopic from telegram._menubutton import MenuButton +from telegram._reaction import ReactionType from telegram._telegramobject import TelegramObject from telegram._utils import enum from telegram._utils.argumentparsing import parse_sequence_arg @@ -53,12 +54,15 @@ InputMediaPhoto, InputMediaVideo, LabeledPrice, + LinkPreviewOptions, Location, Message, MessageEntity, MessageId, PhotoSize, + ReplyParameters, Sticker, + UserChatBoosts, Venue, Video, VideoNote, @@ -126,7 +130,11 @@ class Chat(TelegramObject): be forwarded to other chats. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 13.9 + has_visible_history (:obj:`bool`, optional): :obj:`True`, if new chat members will have + access to old messages; available only to chat administrators. Returned only in + :meth:`telegram.Bot.get_chat`. + .. versionadded:: NEXT.VERSION sticker_set_name (:obj:`str`, optional): For supergroups, name of group sticker set. Returned only in :meth:`telegram.Bot.get_chat`. can_set_sticker_set (:obj:`bool`, optional): :obj:`True`, if the bot can change group the @@ -161,14 +169,43 @@ class Chat(TelegramObject): only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.0 + available_reactions (Sequence[:class:`telegram.ReactionType`], optional): List of available + reactions allowed in the chat. If omitted, then all of + :const:`telegram.constants.ReactionEmoji` are allowed. Returned only in + :meth:`telegram.Bot.get_chat`. + + .. versionadded:: NEXT.VERSION + accent_color_id (:obj:`int`, optional): Identifier of the + :class:`accent color ` for the chat name and + backgrounds of the chat photo, reply header, and link preview. See `accent colors`_ + for more details. Returned only in :meth:`telegram.Bot.get_chat`. Always returned in + :meth:`telegram.Bot.get_chat`. + + .. versionadded:: NEXT.VERSION + background_custom_emoji_id (:obj:`str`, optional): Custom emoji identifier of emoji chosen + by the chat for the reply header and link preview background. Returned only in + :meth:`telegram.Bot.get_chat`. + + .. versionadded:: NEXT.VERSION + profile_accent_color_id (:obj:`int`, optional): Identifier of the + :class:`accent color ` for the chat's profile + background. See profile `accent colors`_ for more details. Returned only in + :meth:`telegram.Bot.get_chat`. + + .. versionadded:: NEXT.VERSION + profile_background_custom_emoji_id (:obj:`str`, optional): Custom emoji identifier of + the emoji chosen by the chat for its profile background. Returned only in + :meth:`telegram.Bot.get_chat`. + + .. versionadded:: NEXT.VERSION emoji_status_custom_emoji_id (:obj:`str`, optional): Custom emoji identifier of emoji - status of the other party in a private chat. Returned only in + status of the chat or the other party in a private chat. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.0 emoji_status_expiration_date (:class:`datetime.datetime`, optional): Expiration date of - emoji status of the other party in a private chat, in seconds. Returned only in - :meth:`telegram.Bot.get_chat`. + emoji status of the chat or the other party in a private chat, in seconds. Returned + only in :meth:`telegram.Bot.get_chat`. |datetime_localization| .. versionadded:: 20.5 @@ -224,6 +261,11 @@ class Chat(TelegramObject): be forwarded to other chats. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 13.9 + has_visible_history (:obj:`bool`): Optional. :obj:`True`, if new chat members will have + access to old messages; available only to chat administrators. Returned only in + :meth:`telegram.Bot.get_chat`. + + .. versionadded:: NEXT.VERSION sticker_set_name (:obj:`str`): Optional. For supergroups, name of Group sticker set. Returned only in :meth:`telegram.Bot.get_chat`. can_set_sticker_set (:obj:`bool`): Optional. :obj:`True`, if the bot can change group the @@ -260,14 +302,43 @@ class Chat(TelegramObject): obtained via :meth:`~telegram.Bot.get_chat`. .. versionadded:: 20.0 + available_reactions (Tuple[:class:`telegram.ReactionType`]): Optional. List of available + reactions allowed in the chat. If omitted, then all of + :const:`telegram.constants.ReactionEmoji` are allowed. Returned only in + :meth:`telegram.Bot.get_chat`. + + .. versionadded:: NEXT.VERSION + accent_color_id (:obj:`int`): Optional. Identifier of the + :class:`accent color ` for the chat name and + backgrounds of the chat photo, reply header, and link preview. See `accent colors`_ + for more details. Returned only in :meth:`telegram.Bot.get_chat`. Always returned in + :meth:`telegram.Bot.get_chat`. + + .. versionadded:: NEXT.VERSION + background_custom_emoji_id (:obj:`str`): Optional. Custom emoji identifier of emoji chosen + by the chat for the reply header and link preview background. Returned only in + :meth:`telegram.Bot.get_chat`. + + .. versionadded:: NEXT.VERSION + profile_accent_color_id (:obj:`int`): Optional. Identifier of the + :class:`accent color ` for the chat's profile + background. See profile `accent colors`_ for more details. Returned only in + :meth:`telegram.Bot.get_chat`. + + .. versionadded:: NEXT.VERSION + profile_background_custom_emoji_id (:obj:`str`): Optional. Custom emoji identifier of + the emoji chosen by the chat for its profile background. Returned only in + :meth:`telegram.Bot.get_chat`. + + .. versionadded:: NEXT.VERSION emoji_status_custom_emoji_id (:obj:`str`): Optional. Custom emoji identifier of emoji - status of the other party in a private chat. Returned only in + status of the chat or the other party in a private chat. Returned only in :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.0 - emoji_status_expiration_date (:class:`datetime.datetime`, optional): Expiration date of - emoji status of the other party in a private chat, in seconds. Returned only in - :meth:`telegram.Bot.get_chat`. + emoji_status_expiration_date (:class:`datetime.datetime`): Optional. Expiration date of + emoji status of the chat or the other party in a private chat, in seconds. Returned + only in :meth:`telegram.Bot.get_chat`. |datetime_localization| .. versionadded:: 20.5 @@ -283,10 +354,14 @@ class Chat(TelegramObject): .. versionadded:: 20.0 .. _topics: https://telegram.org/blog/topics-in-groups-collectible-usernames#topics-in-groups + .. _accent colors: https://core.telegram.org/bots/api#accent-colors """ __slots__ = ( + "accent_color_id", "active_usernames", + "available_reactions", + "background_custom_emoji_id", "bio", "can_set_sticker_set", "description", @@ -298,6 +373,7 @@ class Chat(TelegramObject): "has_private_forwards", "has_protected_content", "has_restricted_voice_and_video_messages", + "has_visible_history", "id", "invite_link", "is_forum", @@ -310,6 +386,8 @@ class Chat(TelegramObject): "permissions", "photo", "pinned_message", + "profile_accent_color_id", + "profile_background_custom_emoji_id", "slow_mode_delay", "sticker_set_name", "title", @@ -362,6 +440,12 @@ def __init__( emoji_status_expiration_date: Optional[datetime] = None, has_aggressive_anti_spam_enabled: Optional[bool] = None, has_hidden_members: Optional[bool] = None, + available_reactions: Optional[Sequence[ReactionType]] = None, + accent_color_id: Optional[int] = None, + background_custom_emoji_id: Optional[str] = None, + profile_accent_color_id: Optional[int] = None, + profile_background_custom_emoji_id: Optional[str] = None, + has_visible_history: Optional[bool] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -386,6 +470,7 @@ def __init__( int(message_auto_delete_time) if message_auto_delete_time is not None else None ) self.has_protected_content: Optional[bool] = has_protected_content + self.has_visible_history: Optional[bool] = has_visible_history self.sticker_set_name: Optional[str] = sticker_set_name self.can_set_sticker_set: Optional[bool] = can_set_sticker_set self.linked_chat_id: Optional[int] = linked_chat_id @@ -401,6 +486,13 @@ def __init__( self.emoji_status_expiration_date: Optional[datetime] = emoji_status_expiration_date self.has_aggressive_anti_spam_enabled: Optional[bool] = has_aggressive_anti_spam_enabled self.has_hidden_members: Optional[bool] = has_hidden_members + self.available_reactions: Optional[Tuple[ReactionType, ...]] = parse_sequence_arg( + available_reactions + ) + self.accent_color_id: Optional[int] = accent_color_id + self.background_custom_emoji_id: Optional[str] = background_custom_emoji_id + self.profile_accent_color_id: Optional[int] = profile_accent_color_id + self.profile_background_custom_emoji_id: Optional[str] = profile_background_custom_emoji_id self._id_attrs = (self.id,) @@ -468,6 +560,7 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Chat"]: data["pinned_message"] = Message.de_json(data.get("pinned_message"), bot) data["permissions"] = ChatPermissions.de_json(data.get("permissions"), bot) data["location"] = ChatLocation.de_json(data.get("location"), bot) + data["available_reactions"] = ReactionType.de_list(data.get("available_reactions"), bot) api_kwargs = {} # This is a deprecated field that TG still returns for backwards compatibility @@ -1326,6 +1419,8 @@ async def send_message( entities: Optional[Sequence["MessageEntity"]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1350,6 +1445,8 @@ async def send_message( disable_web_page_preview=disable_web_page_preview, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + link_preview_options=link_preview_options, + reply_parameters=reply_parameters, reply_markup=reply_markup, allow_sending_without_reply=allow_sending_without_reply, entities=entities, @@ -1362,6 +1459,70 @@ async def send_message( api_kwargs=api_kwargs, ) + async def delete_message( + self, + message_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: Optional[JSONDict] = None, + ) -> bool: + """Shortcut for:: + + await bot.delete_message(update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.delete_message`. + + .. versionadded:: NEXT.VERSION + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().delete_message( + chat_id=self.id, + message_id=message_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def delete_messages( + self, + message_ids: Sequence[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: Optional[JSONDict] = None, + ) -> bool: + """Shortcut for:: + + await bot.delete_messages(update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.delete_messages`. + + .. versionadded:: NEXT.VERSION + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().delete_messages( + chat_id=self.id, + message_ids=message_ids, + 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[ @@ -1372,6 +1533,7 @@ async def send_media_group( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1409,6 +1571,7 @@ async def send_media_group( caption=caption, parse_mode=parse_mode, caption_entities=caption_entities, + reply_parameters=reply_parameters, ) async def send_chat_action( @@ -1459,6 +1622,7 @@ async def send_photo( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, has_spoiler: Optional[bool] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1483,6 +1647,7 @@ async def send_photo( caption=caption, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, parse_mode=parse_mode, allow_sending_without_reply=allow_sending_without_reply, @@ -1510,6 +1675,7 @@ async def send_contact( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, contact: Optional["Contact"] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1535,6 +1701,7 @@ async def send_contact( last_name=last_name, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1564,6 +1731,7 @@ async def send_audio( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1591,6 +1759,7 @@ async def send_audio( caption=caption, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, parse_mode=parse_mode, allow_sending_without_reply=allow_sending_without_reply, @@ -1620,6 +1789,7 @@ async def send_document( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1645,6 +1815,7 @@ async def send_document( caption=caption, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1669,6 +1840,7 @@ async def send_dice( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1690,6 +1862,7 @@ async def send_dice( chat_id=self.id, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1711,6 +1884,7 @@ async def send_game( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1733,6 +1907,7 @@ async def send_game( game_short_name=game_short_name, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1773,6 +1948,7 @@ async def send_invoice( suggested_tip_amounts: Optional[Sequence[int]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1834,6 +2010,7 @@ async def send_invoice( suggested_tip_amounts=suggested_tip_amounts, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, ) async def send_location( @@ -1850,6 +2027,7 @@ async def send_location( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, location: Optional["Location"] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1874,6 +2052,7 @@ async def send_location( longitude=longitude, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1907,6 +2086,7 @@ async def send_animation( message_thread_id: Optional[int] = None, has_spoiler: Optional[bool] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1935,6 +2115,7 @@ async def send_animation( parse_mode=parse_mode, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1960,6 +2141,7 @@ async def send_sticker( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, emoji: Optional[str] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1982,6 +2164,7 @@ async def send_sticker( sticker=sticker, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2010,6 +2193,7 @@ async def send_venue( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, venue: Optional["Venue"] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2037,6 +2221,7 @@ async def send_venue( foursquare_id=foursquare_id, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2070,6 +2255,7 @@ async def send_video( message_thread_id: Optional[int] = None, has_spoiler: Optional[bool] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2095,6 +2281,7 @@ async def send_video( caption=caption, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2126,6 +2313,7 @@ async def send_video_note( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2151,6 +2339,7 @@ async def send_video_note( length=length, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2177,6 +2366,7 @@ async def send_voice( caption_entities: Optional[Sequence["MessageEntity"]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2202,6 +2392,7 @@ async def send_voice( caption=caption, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2236,6 +2427,7 @@ async def send_poll( explanation_entities: Optional[Sequence["MessageEntity"]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2264,6 +2456,7 @@ async def send_poll( is_closed=is_closed, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2293,6 +2486,7 @@ async def send_copy( reply_markup: Optional[ReplyMarkup] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2306,6 +2500,8 @@ async def send_copy( For the documentation of the arguments, please see :meth:`telegram.Bot.copy_message`. + .. seealso:: :meth:`copy_message`, :meth:`send_copies`, :meth:`copy_messages`. + Returns: :class:`telegram.Message`: On success, instance representing the message posted. @@ -2319,6 +2515,7 @@ async def send_copy( caption_entities=caption_entities, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, read_timeout=read_timeout, @@ -2343,6 +2540,7 @@ async def copy_message( reply_markup: Optional[ReplyMarkup] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2356,6 +2554,8 @@ async def copy_message( For the documentation of the arguments, please see :meth:`telegram.Bot.copy_message`. + .. seealso:: :meth:`send_copy`, :meth:`send_copies`, :meth:`copy_messages`. + Returns: :class:`telegram.Message`: On success, instance representing the message posted. @@ -2369,6 +2569,7 @@ async def copy_message( caption_entities=caption_entities, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, read_timeout=read_timeout, @@ -2380,6 +2581,96 @@ async def copy_message( message_thread_id=message_thread_id, ) + async def send_copies( + self, + from_chat_id: Union[str, int], + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: Optional[int] = None, + remove_caption: Optional[bool] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + ) -> Tuple["MessageId", ...]: + """Shortcut for:: + + await bot.copy_messages(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.copy_messages`. + + .. seealso:: :meth:`copy_message`, :meth:`send_copy`, :meth:`copy_messages`. + + .. versionadded:: NEXT.VERSION + + Returns: + Tuple[:class:`telegram.MessageId`]: On success, a tuple of :class:`~telegram.MessageId` + of the sent messages is returned. + + """ + return await self.get_bot().copy_messages( + chat_id=self.id, + from_chat_id=from_chat_id, + message_ids=message_ids, + disable_notification=disable_notification, + protect_content=protect_content, + message_thread_id=message_thread_id, + remove_caption=remove_caption, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def copy_messages( + self, + chat_id: Union[str, int], + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: Optional[int] = None, + remove_caption: Optional[bool] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + ) -> Tuple["MessageId", ...]: + """Shortcut for:: + + await bot.copy_messages(from_chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.copy_messages`. + + .. seealso:: :meth:`copy_message`, :meth:`send_copy`, :meth:`send_copies`. + + .. versionadded:: NEXT.VERSION + + Returns: + Tuple[:class:`telegram.MessageId`]: On success, a tuple of :class:`~telegram.MessageId` + of the sent messages is returned. + + """ + return await self.get_bot().copy_messages( + from_chat_id=self.id, + chat_id=chat_id, + message_ids=message_ids, + disable_notification=disable_notification, + protect_content=protect_content, + message_thread_id=message_thread_id, + remove_caption=remove_caption, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def forward_from( self, from_chat_id: Union[str, int], @@ -2400,7 +2691,7 @@ async def forward_from( For the documentation of the arguments, please see :meth:`telegram.Bot.forward_message`. - .. seealso:: :meth:`forward_to` + .. seealso:: :meth:`forward_to`, :meth:`forward_messages_from`, :meth:`forward_messages_to` .. versionadded:: 20.0 @@ -2442,7 +2733,8 @@ async def forward_to( For the documentation of the arguments, please see :meth:`telegram.Bot.forward_message`. - .. seealso:: :meth:`forward_from` + .. seealso:: :meth:`forward_from`, :meth:`forward_messages_from`, + :meth:`forward_messages_to` .. versionadded:: 20.0 @@ -2464,6 +2756,92 @@ async def forward_to( message_thread_id=message_thread_id, ) + async def forward_messages_from( + self, + from_chat_id: Union[str, int], + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: Optional[int] = 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: Optional[JSONDict] = None, + ) -> Tuple["MessageId", ...]: + """Shortcut for:: + + await bot.forward_messages(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.forward_messages`. + + .. seealso:: :meth:`forward_to`, :meth:`forward_from`, :meth:`forward_messages_to`. + + .. versionadded:: NEXT.VERSION + + Returns: + Tuple[:class:`telegram.MessageId`]: On success, a tuple of :class:`~telegram.MessageId` + of sent messages is returned. + + """ + return await self.get_bot().forward_messages( + chat_id=self.id, + from_chat_id=from_chat_id, + message_ids=message_ids, + disable_notification=disable_notification, + protect_content=protect_content, + message_thread_id=message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def forward_messages_to( + self, + chat_id: Union[int, str], + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: Optional[int] = 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: Optional[JSONDict] = None, + ) -> Tuple["MessageId", ...]: + """Shortcut for:: + + await bot.forward_messages(from_chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.forward_messages`. + + .. seealso:: :meth:`forward_from`, :meth:`forward_to`, :meth:`forward_messages_from`. + + .. versionadded:: NEXT.VERSION + + Returns: + Tuple[:class:`telegram.MessageId`]: On success, a tuple of :class:`~telegram.MessageId` + of sent messages is returned. + + """ + return await self.get_bot().forward_messages( + from_chat_id=self.id, + chat_id=chat_id, + message_ids=message_ids, + disable_notification=disable_notification, + protect_content=protect_content, + message_thread_id=message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def export_invite_link( self, *, @@ -3142,3 +3520,71 @@ async def get_menu_button( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) + + async def get_user_chat_boosts( + 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: Optional[JSONDict] = None, + ) -> "UserChatBoosts": + """Shortcut for:: + + await bot.get_user_chat_boosts(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.get_user_chat_boosts`. + + .. versionadded:: NEXT.VERSION + + Returns: + :class:`telegram.UserChatBoosts`: On success, returns the boosts applied in the chat. + """ + return await self.get_bot().get_user_chat_boosts( + chat_id=self.id, + user_id=user_id, + api_kwargs=api_kwargs, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + ) + + async def set_message_reaction( + self, + message_id: int, + reaction: Optional[Union[Sequence[Union[ReactionType, str]], ReactionType, str]] = None, + is_big: Optional[bool] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + ) -> bool: + """Shortcut for:: + + await bot.set_message_reaction(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.set_message_reaction`. + + .. versionadded:: NEXT.VERSION + + Returns: + :obj:`bool` On success, :obj:`True` is returned. + """ + return await self.get_bot().set_message_reaction( + chat_id=self.id, + message_id=message_id, + reaction=reaction, + is_big=is_big, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) diff --git a/telegram/_chatboost.py b/telegram/_chatboost.py new file mode 100644 index 00000000000..d46ac5d8627 --- /dev/null +++ b/telegram/_chatboost.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# 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 the classes that represent Telegram ChatBoosts.""" + +from datetime import datetime +from typing import TYPE_CHECKING, Dict, Final, Optional, Sequence, Tuple, Type + +from telegram import constants +from telegram._chat import Chat +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils import enum +from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class ChatBoostSource(TelegramObject): + """ + Base class for Telegram ChatBoostSource objects. It can be one of: + + * :class:`telegram.ChatBoostSourcePremium` + * :class:`telegram.ChatBoostSourceGiftCode` + * :class:`telegram.ChatBoostSourceGiveaway` + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`source` is equal. + + .. versionadded:: NEXT.VERSION + + Args: + source (:obj:`str`): The source of the chat boost. Can be one of: + :attr:`~telegram.ChatBoostSource.PREMIUM`, :attr:`~telegram.ChatBoostSource.GIFT_CODE`, + or :attr:`~telegram.ChatBoostSource.GIVEAWAY`. + + Attributes: + source (:obj:`str`): The source of the chat boost. Can be one of: + :attr:`~telegram.ChatBoostSource.PREMIUM`, :attr:`~telegram.ChatBoostSource.GIFT_CODE`, + or :attr:`~telegram.ChatBoostSource.GIVEAWAY`. + """ + + __slots__ = ("source",) + + PREMIUM: Final[str] = constants.ChatBoostSources.PREMIUM + """:const:`telegram.constants.ChatBoostSources.PREMIUM`""" + GIFT_CODE: Final[str] = constants.ChatBoostSources.GIFT_CODE + """:const:`telegram.constants.ChatBoostSources.GIFT_CODE`""" + GIVEAWAY: Final[str] = constants.ChatBoostSources.GIVEAWAY + """:const:`telegram.constants.ChatBoostSources.GIVEAWAY`""" + + def __init__(self, source: str, *, api_kwargs: Optional[JSONDict] = None): + super().__init__(api_kwargs=api_kwargs) + + # Required by all subclasses: + self.source: str = enum.get_member(constants.ChatBoostSources, source, source) + + self._id_attrs = (self.source,) + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatBoostSource"]: + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if not data: + return None + + _class_mapping: Dict[str, Type[ChatBoostSource]] = { + cls.PREMIUM: ChatBoostSourcePremium, + cls.GIFT_CODE: ChatBoostSourceGiftCode, + cls.GIVEAWAY: ChatBoostSourceGiveaway, + } + + if cls is ChatBoostSource and data.get("source") in _class_mapping: + return _class_mapping[data.pop("source")].de_json(data=data, bot=bot) + + if "user" in data: + data["user"] = User.de_json(data.get("user"), bot) + + return super().de_json(data=data, bot=bot) + + +class ChatBoostSourcePremium(ChatBoostSource): + """ + The boost was obtained by subscribing to Telegram Premium or by gifting a Telegram Premium + subscription to another user. + + .. versionadded:: NEXT.VERSION + + Args: + user (:class:`telegram.User`): User that boosted the chat. + + Attributes: + source (:obj:`str`): The source of the chat boost. Always + :attr:`~telegram.ChatBoostSource.PREMIUM`. + user (:class:`telegram.User`): User that boosted the chat. + """ + + __slots__ = ("user",) + + def __init__(self, user: User, *, api_kwargs: Optional[JSONDict] = None): + super().__init__(source=self.PREMIUM, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.user: User = user + + +class ChatBoostSourceGiftCode(ChatBoostSource): + """ + The boost was obtained by the creation of Telegram Premium gift codes to boost a chat. Each + such code boosts the chat 4 times for the duration of the corresponding Telegram Premium + subscription. + + .. versionadded:: NEXT.VERSION + + Args: + user (:class:`telegram.User`): User for which the gift code was created. + + Attributes: + source (:obj:`str`): The source of the chat boost. Always + :attr:`~telegram.ChatBoostSource.GIFT_CODE`. + user (:class:`telegram.User`): User for which the gift code was created. + """ + + __slots__ = ("user",) + + def __init__(self, user: User, *, api_kwargs: Optional[JSONDict] = None): + super().__init__(source=self.GIFT_CODE, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.user: User = user + + +class ChatBoostSourceGiveaway(ChatBoostSource): + """ + The boost was obtained by the creation of a Telegram Premium giveaway. This boosts the chat 4 + times for the duration of the corresponding Telegram Premium subscription. + + .. versionadded:: NEXT.VERSION + + Args: + giveaway_message_id (:obj:`int`): Identifier of a message in the chat with the giveaway; + the message could have been deleted already. May be 0 if the message isn't sent yet. + user (:class:`telegram.User`, optional): User that won the prize in the giveaway if any. + is_unclaimed (:obj:`bool`, optional): :obj:`True`, if the giveaway was completed, but + there was no user to win the prize. + + Attributes: + source (:obj:`str`): Source of the boost. Always + :attr:`~telegram.ChatBoostSource.GIVEAWAY`. + giveaway_message_id (:obj:`int`): Identifier of a message in the chat with the giveaway; + the message could have been deleted already. May be 0 if the message isn't sent yet. + user (:class:`telegram.User`): Optional. User that won the prize in the giveaway if any. + is_unclaimed (:obj:`bool`): Optional. :obj:`True`, if the giveaway was completed, but + there was no user to win the prize. + """ + + __slots__ = ("giveaway_message_id", "is_unclaimed", "user") + + def __init__( + self, + giveaway_message_id: int, + user: Optional[User] = None, + is_unclaimed: Optional[bool] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(source=self.GIVEAWAY, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.giveaway_message_id: int = giveaway_message_id + self.user: Optional[User] = user + self.is_unclaimed: Optional[bool] = is_unclaimed + + +class ChatBoost(TelegramObject): + """ + This object contains information about a chat boost. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`boost_id`, :attr:`add_date`, :attr:`expiration_date`, + and :attr:`source` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + boost_id (:obj:`str`): Unique identifier of the boost. + add_date (:obj:`datetime.datetime`): Point in time when the chat was boosted. + expiration_date (:obj:`datetime.datetime`): Point in time when the boost + will automatically expire, unless the booster's Telegram Premium subscription is + prolonged. + source (:class:`telegram.ChatBoostSource`): Source of the added boost. + + Attributes: + boost_id (:obj:`str`): Unique identifier of the boost. + add_date (:obj:`datetime.datetime`): Point in time when the chat was boosted. + |datetime_localization| + expiration_date (:obj:`datetime.datetime`): Point in time when the boost + will automatically expire, unless the booster's Telegram Premium subscription is + prolonged. |datetime_localization| + source (:class:`telegram.ChatBoostSource`): Source of the added boost. + """ + + __slots__ = ("add_date", "boost_id", "expiration_date", "source") + + def __init__( + self, + boost_id: str, + add_date: datetime, + expiration_date: datetime, + source: ChatBoostSource, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + + self.boost_id: str = boost_id + self.add_date: datetime = add_date + self.expiration_date: datetime = expiration_date + self.source: ChatBoostSource = source + + self._id_attrs = (self.boost_id, self.add_date, self.expiration_date, self.source) + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatBoost"]: + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if not data: + return None + + data["source"] = ChatBoostSource.de_json(data.get("source"), bot) + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["add_date"] = from_timestamp(data["add_date"], tzinfo=loc_tzinfo) + data["expiration_date"] = from_timestamp(data["expiration_date"], tzinfo=loc_tzinfo) + + return super().de_json(data=data, bot=bot) + + +class ChatBoostUpdated(TelegramObject): + """This object represents a boost added to a chat or changed. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`chat`, and :attr:`boost` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + chat (:class:`telegram.Chat`): Chat which was boosted. + boost (:class:`telegram.ChatBoost`): Information about the chat boost. + + Attributes: + chat (:class:`telegram.Chat`): Chat which was boosted. + boost (:class:`telegram.ChatBoost`): Information about the chat boost. + """ + + __slots__ = ("boost", "chat") + + def __init__( + self, + chat: Chat, + boost: ChatBoost, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + + self.chat: Chat = chat + self.boost: ChatBoost = boost + + self._id_attrs = (self.chat.id, self.boost) + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatBoostUpdated"]: + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if not data: + return None + + data["chat"] = Chat.de_json(data.get("chat"), bot) + data["boost"] = ChatBoost.de_json(data.get("boost"), bot) + + return super().de_json(data=data, bot=bot) + + +class ChatBoostRemoved(TelegramObject): + """ + This object represents a boost removed from a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`chat`, :attr:`boost_id`, :attr:`remove_date`, and + :attr:`source` are equal. + + Args: + chat (:class:`telegram.Chat`): Chat which was boosted. + boost_id (:obj:`str`): Unique identifier of the boost. + remove_date (:obj:`datetime.datetime`): Point in time when the boost was removed. + source (:class:`telegram.ChatBoostSource`): Source of the removed boost. + + Attributes: + chat (:class:`telegram.Chat`): Chat which was boosted. + boost_id (:obj:`str`): Unique identifier of the boost. + remove_date (:obj:`datetime.datetime`): Point in time when the boost was removed. + |datetime_localization| + source (:class:`telegram.ChatBoostSource`): Source of the removed boost. + """ + + __slots__ = ("boost_id", "chat", "remove_date", "source") + + def __init__( + self, + chat: Chat, + boost_id: str, + remove_date: datetime, + source: ChatBoostSource, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + + self.chat: Chat = chat + self.boost_id: str = boost_id + self.remove_date: datetime = remove_date + self.source: ChatBoostSource = source + + self._id_attrs = (self.chat, self.boost_id, self.remove_date, self.source) + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatBoostRemoved"]: + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if not data: + return None + + data["chat"] = Chat.de_json(data.get("chat"), bot) + data["source"] = ChatBoostSource.de_json(data.get("source"), bot) + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["remove_date"] = from_timestamp(data["remove_date"], tzinfo=loc_tzinfo) + + return super().de_json(data=data, bot=bot) + + +class UserChatBoosts(TelegramObject): + """This object represents a list of boosts added to a chat by a user. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`boosts` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + boosts (Sequence[:class:`telegram.ChatBoost`]): List of boosts added to the chat by the + user. + + Attributes: + boosts (Tuple[:class:`telegram.ChatBoost`]): List of boosts added to the chat by the user. + """ + + __slots__ = ("boosts",) + + def __init__( + self, + boosts: Sequence[ChatBoost], + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + + self.boosts: Tuple[ChatBoost, ...] = parse_sequence_arg(boosts) + + self._id_attrs = (self.boosts,) + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["UserChatBoosts"]: + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if not data: + return None + + data["boosts"] = ChatBoost.de_list(data.get("boosts"), bot) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_forcereply.py b/telegram/_forcereply.py index b331a2a62a2..61466f6107e 100644 --- a/telegram/_forcereply.py +++ b/telegram/_forcereply.py @@ -45,8 +45,8 @@ class ForceReply(TelegramObject): 1) Users that are @mentioned in the :attr:`~telegram.Message.text` of the :class:`telegram.Message` object. - 2) If the bot's message is a reply (has ``reply_to_message_id``), sender of the - original message. + 2) If the bot's message is a reply to a message in the same chat and forum topic, + sender of the original message. input_field_placeholder (:obj:`str`, optional): The placeholder to be shown in the input field when the reply is active; @@ -63,8 +63,8 @@ class ForceReply(TelegramObject): 1) Users that are @mentioned in the :attr:`~telegram.Message.text` of the :class:`telegram.Message` object. - 2) If the bot's message is a reply (has ``reply_to_message_id``), sender of the - original message. + 2) If the bot's message is a reply to a message in the same chat and forum topic, + sender of the original message. input_field_placeholder (:obj:`str`): Optional. The placeholder to be shown in the input field when the reply is active; :tg-const:`telegram.ForceReply.MIN_INPUT_FIELD_PLACEHOLDER`- diff --git a/telegram/_giveaway.py b/telegram/_giveaway.py new file mode 100644 index 00000000000..5c1557b2e41 --- /dev/null +++ b/telegram/_giveaway.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2023 +# 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 objects that are related to Telegram giveaways.""" +import datetime +from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +from telegram._chat import Chat +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot, Message + + +class Giveaway(TelegramObject): + """This object represents a message about a scheduled giveaway. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`chats`, :attr:`winners_selection_date` and + :attr:`winner_count` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + chats (Tuple[:class:`telegram.Chat`]): The list of chats which the user must join to + participate in the giveaway. + winners_selection_date (:class:`datetime.datetime`): The date when the giveaway winner will + be selected. |datetime_localization| + winner_count (:obj:`int`): The number of users which are supposed to be selected as winners + of the giveaway. + only_new_members (:obj:`True`, optional): If :obj:`True`, only users who join the chats + after the giveaway started should be eligible to win. + has_public_winners (:obj:`True`, optional): :obj:`True`, if the list of giveaway winners + will be visible to everyone + prize_description (:obj:`str`, optional): Description of additional giveaway prize + country_codes (Sequence[:obj:`str`]): A list of two-letter ISO 3166-1 alpha-2 + country codes indicating the countries from which eligible users for the giveaway must + come. If empty, then all users can participate in the giveaway. Users with a phone + number that was bought on Fragment can always participate in giveaways. + premium_subscription_month_count (:obj:`int`, optional): The number of months the Telegram + Premium subscription won from the giveaway will be active for. + + Attributes: + chats (Sequence[:class:`telegram.Chat`]): The list of chats which the user must join to + participate in the giveaway. + winners_selection_date (:class:`datetime.datetime`): The date when the giveaway winner will + be selected. |datetime_localization| + winner_count (:obj:`int`): The number of users which are supposed to be selected as winners + of the giveaway. + only_new_members (:obj:`True`): Optional. If :obj:`True`, only users who join the chats + after the giveaway started should be eligible to win. + has_public_winners (:obj:`True`): Optional. :obj:`True`, if the list of giveaway winners + will be visible to everyone + prize_description (:obj:`str`): Optional. Description of additional giveaway prize + country_codes (Tuple[:obj:`str`]): Optional. A tuple of two-letter ISO 3166-1 alpha-2 + country codes indicating the countries from which eligible users for the giveaway must + come. If empty, then all users can participate in the giveaway. Users with a phone + number that was bought on Fragment can always participate in giveaways. + premium_subscription_month_count (:obj:`int`): Optional. The number of months the Telegram + Premium subscription won from the giveaway will be active for. + """ + + __slots__ = ( + "chats", + "country_codes", + "has_public_winners", + "only_new_members", + "premium_subscription_month_count", + "prize_description", + "winner_count", + "winners_selection_date", + ) + + def __init__( + self, + chats: Sequence[Chat], + winners_selection_date: datetime.datetime, + winner_count: int, + only_new_members: Optional[bool] = None, + has_public_winners: Optional[bool] = None, + prize_description: Optional[str] = None, + country_codes: Optional[Sequence[str]] = None, + premium_subscription_month_count: Optional[int] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + + self.chats: Tuple[Chat, ...] = tuple(chats) + self.winners_selection_date: datetime.datetime = winners_selection_date + self.winner_count: int = winner_count + self.only_new_members: Optional[bool] = only_new_members + self.has_public_winners: Optional[bool] = has_public_winners + self.prize_description: Optional[str] = prize_description + self.country_codes: Tuple[str, ...] = parse_sequence_arg(country_codes) + self.premium_subscription_month_count: Optional[int] = premium_subscription_month_count + + self._id_attrs = ( + self.chats, + self.winners_selection_date, + self.winner_count, + ) + + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Giveaway"]: + """See :obj:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if data is None: + return None + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + data["chats"] = tuple(Chat.de_list(data.get("chats"), bot)) + data["winners_selection_date"] = from_timestamp( + data.get("winners_selection_date"), tzinfo=loc_tzinfo + ) + + return super().de_json(data=data, bot=bot) + + +class GiveawayCreated(TelegramObject): + """This object represents a service message about the creation of a scheduled giveaway. + Currently holds no information. + """ + + __slots__ = () + + def __init__(self, *, api_kwargs: Optional[JSONDict] = None): + super().__init__(api_kwargs=api_kwargs) + + self._freeze() + + +class GiveawayWinners(TelegramObject): + """This object represents a message about the completion of a giveaway with public winners. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`chat`, :attr:`giveaway_message_id`, + :attr:`winners_selection_date`, :attr:`winner_count` and :attr:`winners` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + chat (:class:`telegram.Chat`): The chat that created the giveaway + giveaway_message_id (:obj:`int`): Identifier of the message with the giveaway in the chat + winners_selection_date (:class:`datetime.datetime`): Point in time when winners of the + giveaway were selected. |datetime_localization| + winner_count (:obj:`int`): Total number of winners in the giveaway + winners (Sequence[:class:`telegram.User`]): List of up to + :tg-const:`telegram.constants.GiveawayLimit.MAX_WINNERS` winners of the giveaway + additional_chat_count (:obj:`int`, optional): The number of other chats the user had to + join in order to be eligible for the giveaway + premium_subscription_month_count (:obj:`int`, optional): The number of months the Telegram + Premium subscription won from the giveaway will be active for + unclaimed_prize_count (:obj:`int`, optional): Number of undistributed prizes + only_new_members (:obj:`True`, optional): :obj:`True`, if only users who had joined the + chats after the giveaway started were eligible to win + was_refunded (:obj:`True`, optional): :obj:`True`, if the giveaway was canceled because the + payment for it was refunded + prize_description (:obj:`str`, optional): Description of additional giveaway prize + + Attributes: + chat (:class:`telegram.Chat`): The chat that created the giveaway + giveaway_message_id (:obj:`int`): Identifier of the message with the giveaway in the chat + winners_selection_date (:class:`datetime.datetime`): Point in time when winners of the + giveaway were selected. |datetime_localization| + winner_count (:obj:`int`): Total number of winners in the giveaway + winners (Tuple[:class:`telegram.User`]): tuple of up to + :tg-const:`telegram.constants.GiveawayLimit.MAX_WINNERS` winners of the giveaway + additional_chat_count (:obj:`int`): Optional. The number of other chats the user had to + join in order to be eligible for the giveaway + premium_subscription_month_count (:obj:`int`): Optional. The number of months the Telegram + Premium subscription won from the giveaway will be active for + unclaimed_prize_count (:obj:`int`): Optional. Number of undistributed prizes + only_new_members (:obj:`True`): Optional. :obj:`True`, if only users who had joined the + chats after the giveaway started were eligible to win + was_refunded (:obj:`True`): Optional. :obj:`True`, if the giveaway was canceled because the + payment for it was refunded + prize_description (:obj:`str`): Optional. Description of additional giveaway prize + """ + + __slots__ = ( + "additional_chat_count", + "chat", + "giveaway_message_id", + "only_new_members", + "premium_subscription_month_count", + "prize_description", + "unclaimed_prize_count", + "was_refunded", + "winner_count", + "winners", + "winners_selection_date", + ) + + def __init__( + self, + chat: Chat, + giveaway_message_id: int, + winners_selection_date: datetime.datetime, + winner_count: int, + winners: Sequence[User], + additional_chat_count: Optional[int] = None, + premium_subscription_month_count: Optional[int] = None, + unclaimed_prize_count: Optional[int] = None, + only_new_members: Optional[bool] = None, + was_refunded: Optional[bool] = None, + prize_description: Optional[str] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + + self.chat: Chat = chat + self.giveaway_message_id: int = giveaway_message_id + self.winners_selection_date: datetime.datetime = winners_selection_date + self.winner_count: int = winner_count + self.winners: Tuple[User, ...] = tuple(winners) + self.additional_chat_count: Optional[int] = additional_chat_count + self.premium_subscription_month_count: Optional[int] = premium_subscription_month_count + self.unclaimed_prize_count: Optional[int] = unclaimed_prize_count + self.only_new_members: Optional[bool] = only_new_members + self.was_refunded: Optional[bool] = was_refunded + self.prize_description: Optional[str] = prize_description + + self._id_attrs = ( + self.chat, + self.giveaway_message_id, + self.winners_selection_date, + self.winner_count, + self.winners, + ) + + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["GiveawayWinners"]: + """See :obj:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if data is None: + return None + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + data["chat"] = Chat.de_json(data.get("chat"), bot) + data["winners"] = tuple(User.de_list(data.get("winners"), bot)) + data["winners_selection_date"] = from_timestamp( + data.get("winners_selection_date"), tzinfo=loc_tzinfo + ) + + return super().de_json(data=data, bot=bot) + + +class GiveawayCompleted(TelegramObject): + """This object represents a service message about the completion of a giveaway without public + winners. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`winner_count` and :attr:`unclaimed_prize_count` are equal. + + .. versionadded:: NEXT.VERSION + + + Args: + winner_count (:obj:`int`): Number of winners in the giveaway + unclaimed_prize_count (:obj:`int`, optional): Number of undistributed prizes + giveaway_message (:class:`telegram.Message`, optional): Message with the giveaway that was + completed, if it wasn't deleted + + Attributes: + winner_count (:obj:`int`): Number of winners in the giveaway + unclaimed_prize_count (:obj:`int`): Optional. Number of undistributed prizes + giveaway_message (:class:`telegram.Message`): Optional. Message with the giveaway that was + completed, if it wasn't deleted + """ + + __slots__ = ("giveaway_message", "unclaimed_prize_count", "winner_count") + + def __init__( + self, + winner_count: int, + unclaimed_prize_count: Optional[int] = None, + giveaway_message: Optional["Message"] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + + self.winner_count: int = winner_count + self.unclaimed_prize_count: Optional[int] = unclaimed_prize_count + self.giveaway_message: Optional["Message"] = giveaway_message + + self._id_attrs = ( + self.winner_count, + self.unclaimed_prize_count, + ) + + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["GiveawayCompleted"]: + """See :obj:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if data is None: + return None + + # Unfortunately, this needs to be here due to cyclic imports + from telegram._message import Message # pylint: disable=import-outside-toplevel + + data["giveaway_message"] = Message.de_json(data.get("giveaway_message"), bot) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_inline/inputtextmessagecontent.py b/telegram/_inline/inputtextmessagecontent.py index ee2770a1246..65b8aeb5783 100644 --- a/telegram/_inline/inputtextmessagecontent.py +++ b/telegram/_inline/inputtextmessagecontent.py @@ -17,13 +17,20 @@ # 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 the classes that represent Telegram InputTextMessageContent.""" -from typing import Optional, Sequence, Tuple +from typing import TYPE_CHECKING, Optional, Sequence, Tuple from telegram._inline.inputmessagecontent import InputMessageContent from telegram._messageentity import MessageEntity from telegram._utils.argumentparsing import parse_sequence_arg -from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue from telegram._utils.types import JSONDict, ODVInput +from telegram._utils.warnings_transition import ( + warn_about_deprecated_attr_in_property, + warn_for_link_preview_options, +) + +if TYPE_CHECKING: + from telegram._linkpreviewoptions import LinkPreviewOptions class InputTextMessageContent(InputMessageContent): @@ -48,7 +55,21 @@ class InputTextMessageContent(InputMessageContent): |sequenceclassargs| disable_web_page_preview (:obj:`bool`, optional): Disables link previews for links in the - sent message. + sent message. Mutually exclusive with :paramref:`link_preview_options`. + + .. versionchanged:: NEXT.VERSION + Bot API 7.0 introduced :paramref:`link_preview_options` replacing this + argument. PTB will automatically convert this argument to that one, but + for advanced options, please use :paramref:`link_preview_options` directly. + + .. deprecated:: NEXT.VERSION + In future versions, this argument will become keyword only. + + link_preview_options (:obj:`LinkPreviewOptions`, optional): Link preview generation + options for the message. Mutually exclusive with + :paramref:`disable_web_page_preview`. + + .. versionadded:: NEXT.VERSION Attributes: message_text (:obj:`str`): Text of the message to be sent, @@ -62,12 +83,15 @@ class InputTextMessageContent(InputMessageContent): * |tupleclassattrs| * |alwaystuple| - disable_web_page_preview (:obj:`bool`): Optional. Disables link previews for links in the - sent message. + link_preview_options (:obj:`LinkPreviewOptions`): Optional. Link preview generation + options for the message. Mutually exclusive with + :attr:`disable_web_page_preview`. + + .. versionadded:: NEXT.VERSION """ - __slots__ = ("disable_web_page_preview", "entities", "message_text", "parse_mode") + __slots__ = ("entities", "link_preview_options", "message_text", "parse_mode") def __init__( self, @@ -75,16 +99,39 @@ def __init__( parse_mode: ODVInput[str] = DEFAULT_NONE, disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, entities: Optional[Sequence[MessageEntity]] = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, *, api_kwargs: Optional[JSONDict] = None, ): super().__init__(api_kwargs=api_kwargs) + with self._unfrozen(): # Required self.message_text: str = message_text # Optionals self.parse_mode: ODVInput[str] = parse_mode self.entities: Tuple[MessageEntity, ...] = parse_sequence_arg(entities) - self.disable_web_page_preview: ODVInput[bool] = disable_web_page_preview + self.link_preview_options: ODVInput["LinkPreviewOptions"] = ( + warn_for_link_preview_options(disable_web_page_preview, link_preview_options) + ) self._id_attrs = (self.message_text,) + + @property + def disable_web_page_preview(self) -> Optional[bool]: + """Optional[:obj:`bool`]: Disables link previews for links in the sent message. + + .. deprecated:: NEXT.VERSION + """ + warn_about_deprecated_attr_in_property( + deprecated_attr_name="disable_web_page_preview", + new_attr_name="link_preview_options", + bot_api_version="7.0", + stacklevel=2, + ) + if ( + isinstance(self.link_preview_options, DefaultValue) + or self.link_preview_options is None + ): + return None + return bool(self.link_preview_options.is_disabled) diff --git a/telegram/_keyboardbutton.py b/telegram/_keyboardbutton.py index 7b421b56dc1..d219c0522a0 100644 --- a/telegram/_keyboardbutton.py +++ b/telegram/_keyboardbutton.py @@ -18,12 +18,20 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram KeyboardButton.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Union from telegram._keyboardbuttonpolltype import KeyboardButtonPollType -from telegram._keyboardbuttonrequest import KeyboardButtonRequestChat, KeyboardButtonRequestUser +from telegram._keyboardbuttonrequest import ( + KeyboardButtonRequestChat, + KeyboardButtonRequestUser, + KeyboardButtonRequestUsers, +) from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict +from telegram._utils.warnings_transition import ( + warn_about_deprecated_arg_return_new_arg, + warn_about_deprecated_attr_in_property, +) from telegram._webappinfo import WebAppInfo if TYPE_CHECKING: @@ -37,7 +45,8 @@ class KeyboardButton(TelegramObject): Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their :attr:`text`, :attr:`request_contact`, :attr:`request_location`, - :attr:`request_poll`, :attr:`web_app`, :attr:`request_user` and :attr:`request_chat` are equal. + :attr:`request_poll`, :attr:`web_app`, :attr:`request_users` and :attr:`request_chat` are + equal. Note: * Optional fields are mutually exclusive. @@ -47,7 +56,7 @@ class KeyboardButton(TelegramObject): January, 2020. Older clients will display unsupported message. * :attr:`web_app` option will only work in Telegram versions released after 16 April, 2022. Older clients will display unsupported message. - * :attr:`request_user` and :attr:`request_chat` options will only work in Telegram + * :attr:`request_users` and :attr:`request_chat` options will only work in Telegram versions released after 3 February, 2023. Older clients will display unsupported message. @@ -55,7 +64,7 @@ class KeyboardButton(TelegramObject): :attr:`web_app` is considered as well when comparing objects of this type in terms of equality. .. versionchanged:: 20.5 - :attr:`request_user` and :attr:`request_chat` are considered as well when + :attr:`request_users` and :attr:`request_chat` are considered as well when comparing objects of this type in terms of equality. Args: @@ -74,12 +83,20 @@ class KeyboardButton(TelegramObject): Available in private chats only. .. versionadded:: 20.0 - request_user (:class:`KeyboardButtonRequestUser`, optional): If specified, pressing the + request_user (:class:`KeyboardButtonRequestUser` | :class:`KeyboardButtonRequestUsers`, \ + optional): Alias for + :attr:`request_users`. + + .. versionadded:: 20.1 + .. deprecated:: NEXT.VERSION + Bot API 7.0 deprecates this argument in favor of ref`request_users`. + + request_users (:class:`KeyboardButtonRequestUsers`, optional): If specified, pressing the button will open a list of suitable users. Tapping on any user will send its identifier to the bot in a :attr:`telegram.Message.user_shared` service message. Available in private chats only. - .. versionadded:: 20.1 + .. versionadded:: NEXT.VERSION request_chat (:class:`KeyboardButtonRequestChat`, optional): If specified, pressing the button will open a list of suitable chats. Tapping on a chat will send its identifier to the bot in a :attr:`telegram.Message.chat_shared` service message. @@ -102,12 +119,12 @@ class KeyboardButton(TelegramObject): Available in private chats only. .. versionadded:: 20.0 - request_user (:class:`KeyboardButtonRequestUser`): Optional. If specified, pressing the + request_users (:class:`KeyboardButtonRequestUsers`): Optional. If specified, pressing the button will open a list of suitable users. Tapping on any user will send its identifier to the bot in a :attr:`telegram.Message.user_shared` service message. Available in private chats only. - .. versionadded:: 20.1 + .. versionadded:: NEXT.VERSION request_chat (:class:`KeyboardButtonRequestChat`): Optional. If specified, pressing the button will open a list of suitable chats. Tapping on a chat will send its identifier to the bot in a :attr:`telegram.Message.chat_shared` service message. @@ -121,7 +138,7 @@ class KeyboardButton(TelegramObject): "request_contact", "request_location", "request_poll", - "request_user", + "request_users", "text", "web_app", ) @@ -133,12 +150,16 @@ def __init__( request_location: Optional[bool] = None, request_poll: Optional[KeyboardButtonPollType] = None, web_app: Optional[WebAppInfo] = None, - request_user: Optional[KeyboardButtonRequestUser] = None, + request_user: Optional[ + Union[KeyboardButtonRequestUsers, KeyboardButtonRequestUser] + ] = None, request_chat: Optional[KeyboardButtonRequestChat] = None, + request_users: Optional[KeyboardButtonRequestUsers] = None, *, api_kwargs: Optional[JSONDict] = None, ): super().__init__(api_kwargs=api_kwargs) + # Required self.text: str = text # Optionals @@ -146,7 +167,15 @@ def __init__( self.request_location: Optional[bool] = request_location self.request_poll: Optional[KeyboardButtonPollType] = request_poll self.web_app: Optional[WebAppInfo] = web_app - self.request_user: Optional[KeyboardButtonRequestUser] = request_user + self.request_users: Optional[KeyboardButtonRequestUsers] = ( + warn_about_deprecated_arg_return_new_arg( + deprecated_arg=request_user, + new_arg=request_users, + deprecated_arg_name="request_user", + new_arg_name="request_users", + bot_api_version="7.0", + ) + ) self.request_chat: Optional[KeyboardButtonRequestChat] = request_chat self._id_attrs = ( @@ -155,12 +184,27 @@ def __init__( self.request_location, self.request_poll, self.web_app, - self.request_user, + self.request_users, self.request_chat, ) self._freeze() + @property + def request_user(self) -> Optional[KeyboardButtonRequestUsers]: + """Optional[:class:`KeyboardButtonRequestUsers`]: Alias for :attr:`request_users`. + + .. versionadded:: 20.1 + .. deprecated:: NEXT.VERSION + Bot API 7.0 deprecates this attribute in favor of :attr:`request_users`. + """ + warn_about_deprecated_attr_in_property( + deprecated_attr_name="request_user", + new_attr_name="request_users", + bot_api_version="7.0", + ) + return self.request_users + @classmethod def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["KeyboardButton"]: """See :meth:`telegram.TelegramObject.de_json`.""" @@ -170,7 +214,7 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["KeyboardButt return None data["request_poll"] = KeyboardButtonPollType.de_json(data.get("request_poll"), bot) - data["request_user"] = KeyboardButtonRequestUser.de_json(data.get("request_user"), bot) + data["request_users"] = KeyboardButtonRequestUsers.de_json(data.get("request_users"), bot) data["request_chat"] = KeyboardButtonRequestChat.de_json(data.get("request_chat"), bot) data["web_app"] = WebAppInfo.de_json(data.get("web_app"), bot) diff --git a/telegram/_keyboardbuttonrequest.py b/telegram/_keyboardbuttonrequest.py index 64fbac75789..6ab9b351fb5 100644 --- a/telegram/_keyboardbuttonrequest.py +++ b/telegram/_keyboardbuttonrequest.py @@ -22,12 +22,15 @@ from telegram._chatadministratorrights import ChatAdministratorRights from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict +from telegram._utils.warnings import warn +from telegram._utils.warnings_transition import build_deprecation_warning_message +from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: from telegram import Bot -class KeyboardButtonRequestUser(TelegramObject): +class KeyboardButtonRequestUsers(TelegramObject): """This object defines the criteria used to request a suitable user. The identifier of the selected user will be shared with the bot when the corresponding button is pressed. @@ -38,16 +41,25 @@ class KeyboardButtonRequestUser(TelegramObject): `Telegram Docs on requesting users \ `_ - .. versionadded:: 20.1 + .. versionadded:: NEXT.VERSION + This class was previously named :class:`KeyboardButtonRequestUser`. Args: request_id (:obj:`int`): Signed 32-bit identifier of the request, which will be received - back in the :class:`telegram.UserShared` object. Must be unique within the message. + back in the :class:`telegram.UsersShared` object. Must be unique within the message. user_is_bot (:obj:`bool`, optional): Pass :obj:`True` to request a bot, pass :obj:`False` to request a regular user. If not specified, no additional restrictions are applied. user_is_premium (:obj:`bool`, optional): Pass :obj:`True` to request a premium user, pass :obj:`False` to request a non-premium user. If not specified, no additional restrictions are applied. + max_quantity (:obj:`int`, optional): The maximum number of users to be selected; + :tg-const:`telegram.constants.KeyboardButtonRequestUsersLimit.MIN_QUANTITY` - + :tg-const:`telegram.constants.KeyboardButtonRequestUsersLimit.MAX_QUANTITY`. + Defaults to :tg-const:`telegram.constants.KeyboardButtonRequestUsersLimit.MIN_QUANTITY` + . + + .. versionadded:: NEXT.VERSION + Attributes: request_id (:obj:`int`): Identifier of the request. user_is_bot (:obj:`bool`): Optional. Pass :obj:`True` to request a bot, pass :obj:`False` @@ -55,9 +67,17 @@ class KeyboardButtonRequestUser(TelegramObject): user_is_premium (:obj:`bool`): Optional. Pass :obj:`True` to request a premium user, pass :obj:`False` to request a non-premium user. If not specified, no additional restrictions are applied. + max_quantity (:obj:`int`): Optional. The maximum number of users to be selected; + :tg-const:`telegram.constants.KeyboardButtonRequestUsersLimit.MIN_QUANTITY` - + :tg-const:`telegram.constants.KeyboardButtonRequestUsersLimit.MAX_QUANTITY`. + Defaults to :tg-const:`telegram.constants.KeyboardButtonRequestUsersLimit.MIN_QUANTITY` + . + + .. versionadded:: NEXT.VERSION """ __slots__ = ( + "max_quantity", "request_id", "user_is_bot", "user_is_premium", @@ -68,6 +88,7 @@ def __init__( request_id: int, user_is_bot: Optional[bool] = None, user_is_premium: Optional[bool] = None, + max_quantity: Optional[int] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -78,12 +99,56 @@ def __init__( # Optionals self.user_is_bot: Optional[bool] = user_is_bot self.user_is_premium: Optional[bool] = user_is_premium + self.max_quantity: Optional[int] = max_quantity self._id_attrs = (self.request_id,) self._freeze() +class KeyboardButtonRequestUser(KeyboardButtonRequestUsers): + """Alias for :class:`KeyboardButtonRequestUsers`, kept for backward compatibility. + + .. versionadded:: 20.1 + + .. deprecated:: NEXT.VERSION + Use :class:`KeyboardButtonRequestUsers` instead. + + """ + + __slots__ = () + + def __init__( + self, + request_id: int, + user_is_bot: Optional[bool] = None, + user_is_premium: Optional[bool] = None, + max_quantity: Optional[int] = None, + *, + api_kwargs: Optional[JSONDict] = None, # skipcq: PYL-W0622 + ): + super().__init__( + request_id=request_id, + user_is_bot=user_is_bot, + user_is_premium=user_is_premium, + max_quantity=max_quantity, + api_kwargs=api_kwargs, + ) + + warn( + build_deprecation_warning_message( + deprecated_name="KeyboardButtonRequestUser", + new_name="KeyboardButtonRequestUsers", + object_type="class", + bot_api_version="7.0", + ), + PTBDeprecationWarning, + stacklevel=2, + ) + + self._freeze() + + class KeyboardButtonRequestChat(TelegramObject): """This object defines the criteria used to request a suitable chat. The identifier of the selected user will be shared with the bot when the corresponding button is pressed. diff --git a/telegram/_linkpreviewoptions.py b/telegram/_linkpreviewoptions.py new file mode 100644 index 00000000000..aa7e5197df0 --- /dev/null +++ b/telegram/_linkpreviewoptions.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# 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 the LinkPreviewOptions class.""" + + +from typing import Optional + +from telegram._telegramobject import TelegramObject +from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram._utils.types import JSONDict, ODVInput + + +class LinkPreviewOptions(TelegramObject): + """ + Describes the options used for link preview generation. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`is_disabled`, :attr:`url`, :attr:`prefer_small_media`, + :attr:`prefer_large_media`, and :attr:`show_above_text` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + is_disabled (:obj:`bool`, optional): :obj:`True`, if the link preview is disabled. + url (:obj:`str`, optional): The URL to use for the link preview. If empty, then the first + URL found in the message text will be used. + prefer_small_media (:obj:`bool`, optional): :obj:`True`, if the media in the link preview + is supposed to be shrunk; ignored if the URL isn't explicitly specified or media size + change isn't supported for the preview. + prefer_large_media (:obj:`bool`, optional): :obj:`True`, if the media in the link preview + is supposed to be enlarged; ignored if the URL isn't explicitly specified or media + size change isn't supported for the preview. + show_above_text (:obj:`bool`, optional): :obj:`True`, if the link preview must be shown + above the message text; otherwise, the link preview will be shown below the message + text. + + Attributes: + is_disabled (:obj:`bool`): Optional. :obj:`True`, if the link preview is disabled. + url (:obj:`str`): Optional. The URL to use for the link preview. If empty, then the first + URL found in the message text will be used. + prefer_small_media (:obj:`bool`): Optional. :obj:`True`, if the media in the link preview + is supposed to be shrunk; ignored if the URL isn't explicitly specified or media size + change isn't supported for the preview. + prefer_large_media (:obj:`bool`): Optional. :obj:`True`, if the media in the link preview + is supposed to be enlarged; ignored if the URL isn't explicitly specified or media size + change isn't supported for the preview. + show_above_text (:obj:`bool`): Optional. :obj:`True`, if the link preview must be shown + above the message text; otherwise, the link preview will be shown below the message + text. + """ + + __slots__ = ( + "is_disabled", + "prefer_large_media", + "prefer_small_media", + "show_above_text", + "url", + ) + + def __init__( + self, + is_disabled: ODVInput[bool] = DEFAULT_NONE, + url: ODVInput[str] = DEFAULT_NONE, + prefer_small_media: ODVInput[bool] = DEFAULT_NONE, + prefer_large_media: ODVInput[bool] = DEFAULT_NONE, + show_above_text: ODVInput[bool] = DEFAULT_NONE, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + + # Optionals + + self.is_disabled: ODVInput[bool] = is_disabled + self.url: ODVInput[str] = url + self.prefer_small_media: ODVInput[bool] = prefer_small_media + self.prefer_large_media: ODVInput[bool] = prefer_large_media + self.show_above_text: ODVInput[bool] = show_above_text + + self._id_attrs = ( + self.is_disabled, + self.url, + self.prefer_small_media, + self.prefer_large_media, + self.show_above_text, + ) + self._freeze() diff --git a/telegram/_message.py b/telegram/_message.py index 1ac2a6e64ef..af6916a5487 100644 --- a/telegram/_message.py +++ b/telegram/_message.py @@ -19,8 +19,9 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Message.""" import datetime +import re from html import escape -from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple, TypedDict, Union from telegram._chat import Chat from telegram._dice import Dice @@ -45,6 +46,7 @@ ) from telegram._games.game import Game from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup +from telegram._linkpreviewoptions import LinkPreviewOptions from telegram._messageautodeletetimerchanged import MessageAutoDeleteTimerChanged from telegram._messageentity import MessageEntity from telegram._passport.passportdata import PassportData @@ -52,7 +54,8 @@ from telegram._payment.successfulpayment import SuccessfulPayment from telegram._poll import Poll from telegram._proximityalerttriggered import ProximityAlertTriggered -from telegram._shared import ChatShared, UserShared +from telegram._reply import ReplyParameters +from telegram._shared import ChatShared, UserShared, UsersShared from telegram._story import Story from telegram._telegramobject import TelegramObject from telegram._user import User @@ -67,6 +70,11 @@ ODVInput, ReplyMarkup, ) +from telegram._utils.warnings import warn +from telegram._utils.warnings_transition import ( + build_deprecation_warning_message, + warn_about_deprecated_attr_in_property, +) from telegram._videochat import ( VideoChatEnded, VideoChatParticipantsInvited, @@ -75,13 +83,19 @@ ) from telegram._webappdata import WebAppData from telegram._writeaccessallowed import WriteAccessAllowed -from telegram.constants import MessageAttachmentType, ParseMode +from telegram.constants import ZERO_DATE, MessageAttachmentType, ParseMode from telegram.helpers import escape_markdown +from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: from telegram import ( Bot, + ExternalReplyInfo, GameHighScore, + Giveaway, + GiveawayCompleted, + GiveawayCreated, + GiveawayWinners, InputMedia, InputMediaAudio, InputMediaDocument, @@ -89,10 +103,181 @@ InputMediaVideo, LabeledPrice, MessageId, + MessageOrigin, + ReactionType, + TextQuote, ) -class Message(TelegramObject): +class _ReplyKwargs(TypedDict): + __slots__ = ("chat_id", "reply_parameters") # type: ignore[misc] + + chat_id: Union[str, int] + reply_parameters: ReplyParameters + + +class MaybeInaccessibleMessage(TelegramObject): + """Base class for Telegram Message Objects. + + Currently, that includes :class:`telegram.Message` and :class:`telegram.InaccessibleMessage`. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`message_id` and :attr:`chat` are equal + + .. versionadded:: NEXT.VERSION + + Args: + message_id (:obj:`int`): Unique message identifier. + date (:class:`datetime.datetime`): Date the message was sent in Unix time or 0 in Unix + time. Converted to :class:`datetime.datetime` + + |datetime_localization| + chat (:class:`telegram.Chat`): Conversation the message belongs to. + + Attributes: + message_id (:obj:`int`): Unique message identifier. + date (:class:`datetime.datetime`): Date the message was sent in Unix time or 0 in Unix + time. Converted to :class:`datetime.datetime` + + |datetime_localization| + chat (:class:`telegram.Chat`): Conversation the message belongs to. + """ + + __slots__ = ("chat", "date", "message_id") + + def __init__( + self, + chat: Chat, + message_id: int, + date: datetime.datetime, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.chat: Chat = chat + self.message_id: int = message_id + self.date: datetime.datetime = date + + self._id_attrs = (self.message_id, self.chat) + + self._freeze() + + def __bool__(self) -> bool: + """Overrides :meth:`object.__bool__` to return the value of :attr:`is_accessible`. + This is intended to ease migration to Bot API 7.0, as this allows checks like + + .. code-block:: python + + if message.pinned_message: + ... + + to work as before, when ``message.pinned_message`` was :obj:`None`. Note that this does not + help with check like + + .. code-block:: python + + if message.pinned_message is None: + ... + + for cases where ``message.pinned_message`` is now no longer :obj:`None`. + + Tip: + Since objects that can only be of type :class:`~telegram.Message` or :obj:`None` are + not affected by this change, :meth:`Message.__bool__` is not overridden and will + continue to work as before. + + .. versionadded:: NEXT.VERSION + .. deprecated:: NEXT.VERSION + This behavior is introduced only temporarily to ease migration to Bot API 7.0. It will + be removed along with other functionality deprecated by Bot API 7.0. + """ + # Once we remove this method, also remove `Message.__bool__`. + warn( + category=PTBDeprecationWarning, + message=( + "You probably see this warning " + "because you wrote `if callback_query.message` or `if message.pinned_message` in " + "your code. This is not the supported way of checking the existence of a message " + "as of API 7.0. Please use `if message.is_accessible` or `if isinstance(message, " + "Message)` instead. `if message is None` may be suitable for specific use cases " + f"as well.\n`{self.__class__.__name__}.__bool__` will be reverted to Pythons " + f"default implementation in future versions." + ), + stacklevel=2, + ) + return self.is_accessible + + @property + def is_accessible(self) -> bool: + """Convenience attribute. :obj:`True`, if the date is not 0 in Unix time. + + .. versionadded:: NEXT.VERSION + """ + # Once we drop support for python 3.9, this can be made a TypeGuard function: + # def is_accessible(self) -> TypeGuard[Message]: + return self.date != ZERO_DATE + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["MaybeInaccessibleMessage"]: + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if not data: + return None + + if cls is MaybeInaccessibleMessage: + if data["date"] == 0: + return InaccessibleMessage.de_json(data=data, bot=bot) + return Message.de_json(data=data, bot=bot) + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + # this is to include the Literal from InaccessibleMessage + if data["date"] == 0: + data["date"] = ZERO_DATE + else: + data["date"] = from_timestamp(data["date"], tzinfo=loc_tzinfo) + + data["chat"] = Chat.de_json(data.get("chat"), bot) + return super().de_json(data=data, bot=bot) + + +class InaccessibleMessage(MaybeInaccessibleMessage): + """This object represents an inaccessible message. + + These are messages that are e.g. deleted. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`message_id` and :attr:`chat` are equal + + .. versionadded:: NEXT.VERSION + + Args: + message_id (:obj:`int`): Unique message identifier. + chat (:class:`telegram.Chat`): Chat the message belongs to. + + Attributes: + message_id (:obj:`int`): Unique message identifier. + date (:class:`constants.ZERO_DATE`): Always :tg-const:`telegram.constants.ZERO_DATE`. + The field can be used to differentiate regular and inaccessible messages. + chat (:class:`telegram.Chat`): Chat the message belongs to. + """ + + __slots__ = () + + def __init__( + self, + chat: Chat, + message_id: int, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(chat=chat, message_id=message_id, date=ZERO_DATE, api_kwargs=api_kwargs) + self._freeze() + + +class Message(MaybeInaccessibleMessage): # fmt: off """This object represents a message. @@ -102,6 +287,11 @@ class Message(TelegramObject): Note: In Python :keyword:`from` is a reserved word. Use :paramref:`from_user` instead. + .. versionchanged:: NEXT.VERSION + * This class is now a subclass of :class:`telegram.MaybeInaccessibleMessage`. + * The :paramref:`pinned_message` now can be either class:`telegram.Message` or + class:`telegram.InaccessibleMessage`. + .. versionchanged:: 20.0 * The arguments and attributes ``voice_chat_scheduled``, ``voice_chat_started`` and @@ -135,17 +325,37 @@ class Message(TelegramObject): chat (:class:`telegram.Chat`): Conversation the message belongs to. forward_from (:class:`telegram.User`, optional): For forwarded messages, sender of the original message. + + .. deprecated:: NEXT.VERSION + Bot API 7.0 deprecates :paramref:`forward_from` in favor of + :paramref:`forward_origin`. forward_from_chat (:class:`telegram.Chat`, optional): For messages forwarded from channels or from anonymous administrators, information about the original sender chat. + + .. deprecated:: NEXT.VERSION + Bot API 7.0 deprecates :paramref:`forward_from_chat` in favor of + :paramref:`forward_origin`. forward_from_message_id (:obj:`int`, optional): For forwarded channel posts, identifier of the original message in the channel. + + .. deprecated:: NEXT.VERSION + Bot API 7.0 deprecates :paramref:`forward_from_message_id` in favor of + :paramref:`forward_origin`. forward_sender_name (:obj:`str`, optional): Sender's name for messages forwarded from users who disallow adding a link to their account in forwarded messages. + + .. deprecated:: NEXT.VERSION + Bot API 7.0 deprecates :paramref:`forward_sender_name` in favor of + :paramref:`forward_origin`. forward_date (:class:`datetime.datetime`, optional): For forwarded messages, date the original message was sent in Unix time. Converted to :class:`datetime.datetime`. .. versionchanged:: 20.3 |datetime_localization| + + .. deprecated:: NEXT.VERSION + Bot API 7.0 deprecates :paramref:`forward_date` in favor of + :paramref:`forward_origin`. is_automatic_forward (:obj:`bool`, optional): :obj:`True`, if the message is a channel post that was automatically forwarded to the connected discussion group. @@ -174,6 +384,12 @@ class Message(TelegramObject): .. versionchanged:: 20.0 |sequenceclassargs| + link_preview_options (:class:`telegram.LinkPreviewOptions`, optional): Options used for + link preview generation for the message, if it is a text message and link preview + options were changed. + + .. versionadded:: NEXT.VERSION + caption_entities (Sequence[:class:`telegram.MessageEntity`], optional): For messages with a Caption. Special entities like usernames, URLs, bot commands, etc. that appear in the caption. See :attr:`Message.parse_caption_entity` and :attr:`parse_caption_entities` @@ -252,9 +468,13 @@ class Message(TelegramObject): with the specified identifier. migrate_from_chat_id (:obj:`int`, optional): The supergroup has been migrated from a group with the specified identifier. - pinned_message (:class:`telegram.Message`, optional): Specified message was pinned. Note - that the Message object in this field will not contain further + pinned_message (:class:`telegram.MaybeInaccessibleMessage`, optional): Specified message + was pinned. Note that the Message object in this field will not contain further :attr:`reply_to_message` fields even if it is itself a reply. + + .. versionchanged:: NEXT.VERSION + This attribute now is either class:`telegram.Message` or + class:`telegram.InaccessibleMessage`. invoice (:class:`telegram.Invoice`, optional): Message is an invoice for a payment, information about the invoice. successful_payment (:class:`telegram.SuccessfulPayment`, optional): Message is a service @@ -263,6 +483,10 @@ class Message(TelegramObject): has logged in. forward_signature (:obj:`str`, optional): For messages forwarded from channels, signature of the post author if present. + + .. deprecated:: NEXT.VERSION + Bot API 7.0 deprecates :paramref:`forward_signature` in favor of + :paramref:`forward_origin`. author_signature (:obj:`str`, optional): Signature of the post author for messages in channels, or the custom title of an anonymous group administrator. passport_data (:class:`telegram.PassportData`, optional): Telegram Passport data. @@ -343,10 +567,44 @@ class Message(TelegramObject): with the bot. .. versionadded:: 20.1 + .. deprecated:: NEXT.VERSION + Bot API 7.0 deprecates :paramref:`user_shared` in favor of :paramref:`users_shared`. + users_shared (:class:`telegram.UsersShared`, optional): Service message: users were shared + with the bot + + .. versionadded:: NEXT.VERSION chat_shared (:class:`telegram.ChatShared`, optional):Service message: a chat was shared with the bot. .. versionadded:: 20.1 + giveaway_created (:class:`telegram.GiveawayCreated`, optional): Service message: a + scheduled giveaway was created + + .. versionadded:: NEXT.VERSION + giveaway (:class:`telegram.Giveaway`, optional): The message is a scheduled giveaway + message + + .. versionadded:: NEXT.VERSION + giveaway_winners (:class:`telegram.GiveawayWinners`, optional): A giveaway with public + winners was completed + + .. versionadded:: NEXT.VERSION + giveaway_completed (:class:`telegram.GiveawayCompleted`, optional): Service message: a + giveaway without public winners was completed + + .. versionadded:: NEXT.VERSION + external_reply (:class:`telegram.ExternalReplyInfo`, optional): Information about the + message that is being replied to, which may come from another chat or forum topic. + + .. versionadded:: NEXT.VERSION + quote (:class:`telegram.TextQuote`, optional): For replies that quote part of the original + message, the quoted part of the message. + + .. versionadded:: NEXT.VERSION + forward_origin (:class:`telegram.MessageOrigin`, optional): Information about the original + message for forwarded messages + + .. versionadded:: NEXT.VERSION Attributes: message_id (:obj:`int`): Unique message identifier inside this chat. @@ -365,17 +623,6 @@ class Message(TelegramObject): .. versionchanged:: 20.3 |datetime_localization| chat (:class:`telegram.Chat`): Conversation the message belongs to. - forward_from (:class:`telegram.User`): Optional. For forwarded messages, sender of the - original message. - forward_from_chat (:class:`telegram.Chat`): Optional. For messages forwarded from channels - or from anonymous administrators, information about the original sender chat. - forward_from_message_id (:obj:`int`): Optional. For forwarded channel posts, identifier of - the original message in the channel. - forward_date (:class:`datetime.datetime`): Optional. For forwarded messages, date the - original message was sent in Unix time. Converted to :class:`datetime.datetime`. - - .. versionchanged:: 20.3 - |datetime_localization| is_automatic_forward (:obj:`bool`): Optional. :obj:`True`, if the message is a channel post that was automatically forwarded to the connected discussion group. @@ -404,6 +651,12 @@ class Message(TelegramObject): .. versionchanged:: 20.0 |tupleclassattrs| + link_preview_options (:class:`telegram.LinkPreviewOptions`): Optional. Options used for + link preview generation for the message, if it is a text message and link preview + options were changed. + + .. versionadded:: NEXT.VERSION + caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. For messages with a Caption. Special entities like usernames, URLs, bot commands, etc. that appear in the caption. See :attr:`Message.parse_caption_entity` and :attr:`parse_caption_entities` @@ -497,21 +750,21 @@ class Message(TelegramObject): with the specified identifier. migrate_from_chat_id (:obj:`int`): Optional. The supergroup has been migrated from a group with the specified identifier. - pinned_message (:class:`telegram.Message`): Optional. Specified message was pinned. Note - that the Message object in this field will not contain further + pinned_message (:class:`telegram.MaybeInaccessibleMessage`): Optional. Specified message + was pinned. Note that the Message object in this field will not contain further :attr:`reply_to_message` fields even if it is itself a reply. + + .. versionchanged:: NEXT.VERSION + This attribute now is either class:`telegram.Message` or + class:`telegram.InaccessibleMessage`. invoice (:class:`telegram.Invoice`): Optional. Message is an invoice for a payment, information about the invoice. successful_payment (:class:`telegram.SuccessfulPayment`): Optional. Message is a service message about a successful payment, information about the payment. connected_website (:obj:`str`): Optional. The domain name of the website on which the user has logged in. - forward_signature (:obj:`str`): Optional. For messages forwarded from channels, signature - of the post author if present. author_signature (:obj:`str`): Optional. Signature of the post author for messages in channels, or the custom title of an anonymous group administrator. - forward_sender_name (:obj:`str`): Optional. Sender's name for messages forwarded from - users who disallow adding a link to their account in forwarded messages. passport_data (:class:`telegram.PassportData`): Optional. Telegram Passport data. Examples: @@ -586,56 +839,90 @@ class Message(TelegramObject): by a spoiler animation. .. versionadded:: 20.0 - user_shared (:class:`telegram.UserShared`): Optional. Service message: a user was shared - with the bot. + users_shared (:class:`telegram.UsersShared`): Optional. Service message: users were shared + with the bot - .. versionadded:: 20.1 + .. versionadded:: NEXT.VERSION chat_shared (:class:`telegram.ChatShared`): Optional. Service message: a chat was shared with the bot. .. versionadded:: 20.1 + giveaway_created (:class:`telegram.GiveawayCreated`): Optional. Service message: a + scheduled giveaway was created + + .. versionadded:: NEXT.VERSION + giveaway (:class:`telegram.Giveaway`): Optional. The message is a scheduled giveaway + message + + .. versionadded:: NEXT.VERSION + giveaway_winners (:class:`telegram.GiveawayWinners`): Optional. A giveaway with public + winners was completed + + .. versionadded:: NEXT.VERSION + giveaway_completed (:class:`telegram.GiveawayCompleted`): Optional. Service message: a + giveaway without public winners was completed - .. |custom_emoji_formatting_note| replace:: Custom emoji entities will be ignored by this - function. Instead, the supplied replacement for the emoji will be used. + .. versionadded:: NEXT.VERSION + external_reply (:class:`telegram.ExternalReplyInfo`): Optional. Information about the + message that is being replied to, which may come from another chat or forum topic. + + .. versionadded:: NEXT.VERSION + quote (:class:`telegram.TextQuote`): Optional. For replies that quote part of the original + message, the quoted part of the message. + + .. versionadded:: NEXT.VERSION + forward_origin (:class:`telegram.MessageOrigin`, optional): Information about the original + message for forwarded messages + + .. versionadded:: NEXT.VERSION .. |custom_emoji_no_md1_support| replace:: Since custom emoji entities are not supported by :attr:`~telegram.constants.ParseMode.MARKDOWN`, this method now raises a :exc:`ValueError` when encountering a custom emoji. + + .. |blockquote_no_md1_support| replace:: Since block quotation entities are not supported + by :attr:`~telegram.constants.ParseMode.MARKDOWN`, this method now raises a + :exc:`ValueError` when encountering a block quotation. """ # fmt: on __slots__ = ( "_effective_attachment", + "_forward_date", + "_forward_from", + "_forward_from_chat", + "_forward_from_message_id", + "_forward_sender_name", + "_forward_signature", + "_user_shared", "animation", "audio", "author_signature", "caption", "caption_entities", "channel_chat_created", - "chat", "chat_shared", "connected_website", "contact", - "date", "delete_chat_photo", "dice", "document", "edit_date", "entities", + "external_reply", "forum_topic_closed", "forum_topic_created", "forum_topic_edited", "forum_topic_reopened", - "forward_date", - "forward_from", - "forward_from_chat", - "forward_from_message_id", - "forward_sender_name", - "forward_signature", + "forward_origin", "from_user", "game", "general_forum_topic_hidden", "general_forum_topic_unhidden", + "giveaway", + "giveaway_completed", + "giveaway_created", + "giveaway_winners", "group_chat_created", "has_media_spoiler", "has_protected_content", @@ -643,10 +930,10 @@ class Message(TelegramObject): "is_automatic_forward", "is_topic_message", "left_chat_member", + "link_preview_options", "location", "media_group_id", "message_auto_delete_timer_changed", - "message_id", "message_thread_id", "migrate_from_chat_id", "migrate_to_chat_id", @@ -658,6 +945,7 @@ class Message(TelegramObject): "pinned_message", "poll", "proximity_alert_triggered", + "quote", "reply_markup", "reply_to_message", "sender_chat", @@ -666,7 +954,7 @@ class Message(TelegramObject): "successful_payment", "supergroup_chat_created", "text", - "user_shared", + "users_shared", "venue", "via_bot", "video", @@ -717,7 +1005,7 @@ def __init__( channel_chat_created: Optional[bool] = None, migrate_to_chat_id: Optional[int] = None, migrate_from_chat_id: Optional[int] = None, - pinned_message: Optional["Message"] = None, + pinned_message: Optional[MaybeInaccessibleMessage] = None, invoice: Optional[Invoice] = None, successful_payment: Optional[SuccessfulPayment] = None, forward_signature: Optional[str] = None, @@ -754,101 +1042,287 @@ def __init__( user_shared: Optional[UserShared] = None, chat_shared: Optional[ChatShared] = None, story: Optional[Story] = None, + giveaway: Optional["Giveaway"] = None, + giveaway_completed: Optional["GiveawayCompleted"] = None, + giveaway_created: Optional["GiveawayCreated"] = None, + giveaway_winners: Optional["GiveawayWinners"] = None, + users_shared: Optional[UsersShared] = None, + link_preview_options: Optional[LinkPreviewOptions] = None, + external_reply: Optional["ExternalReplyInfo"] = None, + quote: Optional["TextQuote"] = None, + forward_origin: Optional["MessageOrigin"] = None, *, api_kwargs: Optional[JSONDict] = None, ): - super().__init__(api_kwargs=api_kwargs) + super().__init__(chat=chat, message_id=message_id, date=date, api_kwargs=api_kwargs) + + if user_shared: + warn( + build_deprecation_warning_message( + deprecated_name="user_shared", + new_name="users_shared", + object_type="parameter", + bot_api_version="7.0", + ), + PTBDeprecationWarning, + stacklevel=2, + ) - # Required - self.message_id: int = message_id - # Optionals - self.from_user: Optional[User] = from_user - self.sender_chat: Optional[Chat] = sender_chat - self.date: datetime.datetime = date - self.chat: Chat = chat - self.forward_from: Optional[User] = forward_from - self.forward_from_chat: Optional[Chat] = forward_from_chat - self.forward_date: Optional[datetime.datetime] = forward_date - self.is_automatic_forward: Optional[bool] = is_automatic_forward - self.reply_to_message: Optional[Message] = reply_to_message - self.edit_date: Optional[datetime.datetime] = edit_date - self.has_protected_content: Optional[bool] = has_protected_content - self.text: Optional[str] = text - self.entities: Tuple[MessageEntity, ...] = parse_sequence_arg(entities) - self.caption_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.audio: Optional[Audio] = audio - self.game: Optional[Game] = game - self.document: Optional[Document] = document - self.photo: Tuple[PhotoSize, ...] = parse_sequence_arg(photo) - self.sticker: Optional[Sticker] = sticker - self.video: Optional[Video] = video - self.voice: Optional[Voice] = voice - self.video_note: Optional[VideoNote] = video_note - self.caption: Optional[str] = caption - self.contact: Optional[Contact] = contact - self.location: Optional[Location] = location - self.venue: Optional[Venue] = venue - self.new_chat_members: Tuple[User, ...] = parse_sequence_arg(new_chat_members) - self.left_chat_member: Optional[User] = left_chat_member - self.new_chat_title: Optional[str] = new_chat_title - self.new_chat_photo: Tuple[PhotoSize, ...] = parse_sequence_arg(new_chat_photo) - self.delete_chat_photo: Optional[bool] = bool(delete_chat_photo) - self.group_chat_created: Optional[bool] = bool(group_chat_created) - self.supergroup_chat_created: Optional[bool] = bool(supergroup_chat_created) - self.migrate_to_chat_id: Optional[int] = migrate_to_chat_id - self.migrate_from_chat_id: Optional[int] = migrate_from_chat_id - self.channel_chat_created: Optional[bool] = bool(channel_chat_created) - self.message_auto_delete_timer_changed: Optional[MessageAutoDeleteTimerChanged] = ( - message_auto_delete_timer_changed - ) - self.pinned_message: Optional[Message] = pinned_message - self.forward_from_message_id: Optional[int] = forward_from_message_id - self.invoice: Optional[Invoice] = invoice - self.successful_payment: Optional[SuccessfulPayment] = successful_payment - self.connected_website: Optional[str] = connected_website - self.forward_signature: Optional[str] = forward_signature - self.forward_sender_name: Optional[str] = forward_sender_name - self.author_signature: Optional[str] = author_signature - self.media_group_id: Optional[str] = media_group_id - self.animation: Optional[Animation] = animation - self.passport_data: Optional[PassportData] = passport_data - self.poll: Optional[Poll] = poll - self.dice: Optional[Dice] = dice - self.via_bot: Optional[User] = via_bot - self.proximity_alert_triggered: Optional[ProximityAlertTriggered] = ( - proximity_alert_triggered - ) - self.video_chat_scheduled: Optional[VideoChatScheduled] = video_chat_scheduled - self.video_chat_started: Optional[VideoChatStarted] = video_chat_started - self.video_chat_ended: Optional[VideoChatEnded] = video_chat_ended - self.video_chat_participants_invited: Optional[VideoChatParticipantsInvited] = ( - video_chat_participants_invited - ) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.web_app_data: Optional[WebAppData] = web_app_data - self.is_topic_message: Optional[bool] = is_topic_message - self.message_thread_id: Optional[int] = message_thread_id - self.forum_topic_created: Optional[ForumTopicCreated] = forum_topic_created - self.forum_topic_closed: Optional[ForumTopicClosed] = forum_topic_closed - self.forum_topic_reopened: Optional[ForumTopicReopened] = forum_topic_reopened - self.forum_topic_edited: Optional[ForumTopicEdited] = forum_topic_edited - self.general_forum_topic_hidden: Optional[GeneralForumTopicHidden] = ( - general_forum_topic_hidden - ) - self.general_forum_topic_unhidden: Optional[GeneralForumTopicUnhidden] = ( - general_forum_topic_unhidden - ) - self.write_access_allowed: Optional[WriteAccessAllowed] = write_access_allowed - self.has_media_spoiler: Optional[bool] = has_media_spoiler - self.user_shared: Optional[UserShared] = user_shared - self.chat_shared: Optional[ChatShared] = chat_shared - self.story: Optional[Story] = story - - self._effective_attachment = DEFAULT_NONE + if any( + ( + forward_from, + forward_from_chat, + forward_from_message_id, + forward_signature, + forward_sender_name, + forward_date, + ) + ): + if forward_from: + _warn_param = "forward_from" + elif forward_from_chat: + _warn_param = "forward_from_chat" + elif forward_from_message_id: + _warn_param = "forward_from_message_id" + elif forward_signature: + _warn_param = "forward_signature" + elif forward_sender_name: + _warn_param = "forward_sender_name" + else: + _warn_param = "forward_date" + + warn( + f"The information about parameter '{_warn_param}' was transferred to " + "'forward_origin' in Bot API 7.0. We recommend using 'forward_origin' instead of " + f"'{_warn_param}'", + PTBDeprecationWarning, + stacklevel=2, + ) - self._id_attrs = (self.message_id, self.chat) + with self._unfrozen(): + # Required + self.message_id: int = message_id + # Optionals + self.from_user: Optional[User] = from_user + self.sender_chat: Optional[Chat] = sender_chat + self.date: datetime.datetime = date + self.chat: Chat = chat + self._forward_from: Optional[User] = forward_from + self._forward_from_chat: Optional[Chat] = forward_from_chat + self._forward_date: Optional[datetime.datetime] = forward_date + self.is_automatic_forward: Optional[bool] = is_automatic_forward + self.reply_to_message: Optional[Message] = reply_to_message + self.edit_date: Optional[datetime.datetime] = edit_date + self.has_protected_content: Optional[bool] = has_protected_content + self.text: Optional[str] = text + self.entities: Tuple[MessageEntity, ...] = parse_sequence_arg(entities) + self.caption_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) + self.audio: Optional[Audio] = audio + self.game: Optional[Game] = game + self.document: Optional[Document] = document + self.photo: Tuple[PhotoSize, ...] = parse_sequence_arg(photo) + self.sticker: Optional[Sticker] = sticker + self.video: Optional[Video] = video + self.voice: Optional[Voice] = voice + self.video_note: Optional[VideoNote] = video_note + self.caption: Optional[str] = caption + self.contact: Optional[Contact] = contact + self.location: Optional[Location] = location + self.venue: Optional[Venue] = venue + self.new_chat_members: Tuple[User, ...] = parse_sequence_arg(new_chat_members) + self.left_chat_member: Optional[User] = left_chat_member + self.new_chat_title: Optional[str] = new_chat_title + self.new_chat_photo: Tuple[PhotoSize, ...] = parse_sequence_arg(new_chat_photo) + self.delete_chat_photo: Optional[bool] = bool(delete_chat_photo) + self.group_chat_created: Optional[bool] = bool(group_chat_created) + self.supergroup_chat_created: Optional[bool] = bool(supergroup_chat_created) + self.migrate_to_chat_id: Optional[int] = migrate_to_chat_id + self.migrate_from_chat_id: Optional[int] = migrate_from_chat_id + self.channel_chat_created: Optional[bool] = bool(channel_chat_created) + self.message_auto_delete_timer_changed: Optional[MessageAutoDeleteTimerChanged] = ( + message_auto_delete_timer_changed + ) + self.pinned_message: Optional[MaybeInaccessibleMessage] = pinned_message + self._forward_from_message_id: Optional[int] = forward_from_message_id + self.invoice: Optional[Invoice] = invoice + self.successful_payment: Optional[SuccessfulPayment] = successful_payment + self.connected_website: Optional[str] = connected_website + self._forward_signature: Optional[str] = forward_signature + self._forward_sender_name: Optional[str] = forward_sender_name + self.author_signature: Optional[str] = author_signature + self.media_group_id: Optional[str] = media_group_id + self.animation: Optional[Animation] = animation + self.passport_data: Optional[PassportData] = passport_data + self.poll: Optional[Poll] = poll + self.dice: Optional[Dice] = dice + self.via_bot: Optional[User] = via_bot + self.proximity_alert_triggered: Optional[ProximityAlertTriggered] = ( + proximity_alert_triggered + ) + self.video_chat_scheduled: Optional[VideoChatScheduled] = video_chat_scheduled + self.video_chat_started: Optional[VideoChatStarted] = video_chat_started + self.video_chat_ended: Optional[VideoChatEnded] = video_chat_ended + self.video_chat_participants_invited: Optional[VideoChatParticipantsInvited] = ( + video_chat_participants_invited + ) + self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup + self.web_app_data: Optional[WebAppData] = web_app_data + self.is_topic_message: Optional[bool] = is_topic_message + self.message_thread_id: Optional[int] = message_thread_id + self.forum_topic_created: Optional[ForumTopicCreated] = forum_topic_created + self.forum_topic_closed: Optional[ForumTopicClosed] = forum_topic_closed + self.forum_topic_reopened: Optional[ForumTopicReopened] = forum_topic_reopened + self.forum_topic_edited: Optional[ForumTopicEdited] = forum_topic_edited + self.general_forum_topic_hidden: Optional[GeneralForumTopicHidden] = ( + general_forum_topic_hidden + ) + self.general_forum_topic_unhidden: Optional[GeneralForumTopicUnhidden] = ( + general_forum_topic_unhidden + ) + self.write_access_allowed: Optional[WriteAccessAllowed] = write_access_allowed + self.has_media_spoiler: Optional[bool] = has_media_spoiler + self._user_shared: Optional[UserShared] = user_shared + self.users_shared: Optional[UsersShared] = users_shared + self.chat_shared: Optional[ChatShared] = chat_shared + self.story: Optional[Story] = story + self.giveaway: Optional[Giveaway] = giveaway + self.giveaway_completed: Optional[GiveawayCompleted] = giveaway_completed + self.giveaway_created: Optional[GiveawayCreated] = giveaway_created + self.giveaway_winners: Optional[GiveawayWinners] = giveaway_winners + self.link_preview_options: Optional[LinkPreviewOptions] = link_preview_options + self.external_reply: Optional[ExternalReplyInfo] = external_reply + self.quote: Optional[TextQuote] = quote + self.forward_origin: Optional[MessageOrigin] = forward_origin + + self._effective_attachment = DEFAULT_NONE + + self._id_attrs = (self.message_id, self.chat) + + def __bool__(self) -> bool: + """Overrides :meth:`telegram.MaybeInaccessibleMessage.__bool__` to use Pythons + default implementation of :meth:`object.__bool__` instead. + + Tip: + The current behavior is the same as before the introduction of + :class:`telegram.MaybeInaccessibleMessage`. This documentation is relevant only until + :meth:`telegram.MaybeInaccessibleMessage.__bool__` is removed. + """ + return True - self._freeze() + @property + def user_shared(self) -> Optional[UserShared]: + """:class:`telegram.UserShared`: Optional. Service message. A user was shared with the + bot. + + Hint: + In case a single user was shared, :attr:`user_shared` will be present in addition to + :attr:`users_shared`. If multiple users where shared, only :attr:`users_shared` will + be present. However, this behavior is not documented and may be changed by Telegram. + + .. versionadded:: 20.1 + .. deprecated:: NEXT.VERSION + Bot API 7.0 deprecates :attr:`user_shared` in favor of :attr:`users_shared`. + """ + warn_about_deprecated_attr_in_property( + deprecated_attr_name="user_shared", + new_attr_name="users_shared", + bot_api_version="7.0", + ) + return self._user_shared + + @property + def forward_from(self) -> Optional[User]: + """:class:`telegram.User`: Optional. For forwarded messages, sender of the original + message. + + .. deprecated:: NEXT.VERSION + Bot API 7.0 deprecates :attr:`forward_from` in favor of :attr:`forward_origin`. + """ + warn_about_deprecated_attr_in_property( + deprecated_attr_name="forward_from", + new_attr_name="forward_origin", + bot_api_version="7.0", + ) + return self._forward_from + + @property + def forward_from_chat(self) -> Optional[Chat]: + """:class:`telegram.Chat`: Optional. For messages forwarded from channels or from anonymous + administrators, information about the original sender chat. + + .. deprecated:: NEXT.VERSION + Bot API 7.0 deprecates :attr:`forward_from_chat` in favor of :attr:`forward_origin`. + """ + warn_about_deprecated_attr_in_property( + deprecated_attr_name="forward_from_chat", + new_attr_name="forward_origin", + bot_api_version="7.0", + ) + return self._forward_from_chat + + @property + def forward_from_message_id(self) -> Optional[int]: + """:obj:`int`: Optional. For forwarded channel posts, identifier of the original message + in the channel. + + .. deprecated:: NEXT.VERSION + Bot API 7.0 deprecates :attr:`forward_from_message_id` in favor of + :attr:`forward_origin`. + """ + warn_about_deprecated_attr_in_property( + deprecated_attr_name="forward_from_message_id", + new_attr_name="forward_origin", + bot_api_version="7.0", + ) + return self._forward_from_message_id + + @property + def forward_signature(self) -> Optional[str]: + """:obj:`str`: Optional. For messages forwarded from channels, signature + of the post author if present. + + .. deprecated:: NEXT.VERSION + Bot API 7.0 deprecates :attr:`forward_signature` in favor of :attr:`forward_origin`. + """ + warn_about_deprecated_attr_in_property( + deprecated_attr_name="forward_signature", + new_attr_name="forward_origin", + bot_api_version="7.0", + ) + return self._forward_signature + + @property + def forward_sender_name(self) -> Optional[str]: + """:class:`telegram.User`: Optional. Sender's name for messages forwarded from users who + disallow adding a link to their account in forwarded messages. + + .. deprecated:: NEXT.VERSION + Bot API 7.0 deprecates :attr:`forward_sender_name` in favor of :attr:`forward_origin`. + """ + warn_about_deprecated_attr_in_property( + deprecated_attr_name="forward_sender_name", + new_attr_name="forward_origin", + bot_api_version="7.0", + ) + return self._forward_sender_name + + @property + def forward_date(self) -> Optional[datetime.datetime]: + """:obj:`datetime.datetime`: Optional. For forwarded messages, date the original message + was sent in Unix time. Converted to :class:`datetime.datetime`. + + .. versionchanged:: 20.3 + |datetime_localization| + + .. deprecated:: NEXT.VERSION + Bot API 7.0 deprecates :attr:`forward_date` in favor of :attr:`forward_origin`. + """ + warn_about_deprecated_attr_in_property( + deprecated_attr_name="forward_date", + new_attr_name="forward_origin", + bot_api_version="7.0", + ) + return self._forward_date @property def chat_id(self) -> int: @@ -897,8 +1371,6 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Message"]: data["from_user"] = User.de_json(data.pop("from", None), bot) data["sender_chat"] = Chat.de_json(data.get("sender_chat"), bot) - data["date"] = from_timestamp(data["date"], tzinfo=loc_tzinfo) - data["chat"] = Chat.de_json(data.get("chat"), bot) data["entities"] = MessageEntity.de_list(data.get("entities"), bot) data["caption_entities"] = MessageEntity.de_list(data.get("caption_entities"), bot) data["forward_from"] = User.de_json(data.get("forward_from"), bot) @@ -925,7 +1397,7 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Message"]: data["message_auto_delete_timer_changed"] = MessageAutoDeleteTimerChanged.de_json( data.get("message_auto_delete_timer_changed"), bot ) - data["pinned_message"] = Message.de_json(data.get("pinned_message"), bot) + data["pinned_message"] = MaybeInaccessibleMessage.de_json(data.get("pinned_message"), bot) data["invoice"] = Invoice.de_json(data.get("invoice"), bot) data["successful_payment"] = SuccessfulPayment.de_json(data.get("successful_payment"), bot) data["passport_data"] = PassportData.de_json(data.get("passport_data"), bot) @@ -963,9 +1435,36 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Message"]: data.get("write_access_allowed"), bot ) data["user_shared"] = UserShared.de_json(data.get("user_shared"), bot) + data["users_shared"] = UsersShared.de_json(data.get("users_shared"), bot) data["chat_shared"] = ChatShared.de_json(data.get("chat_shared"), bot) - return super().de_json(data=data, bot=bot) + # Unfortunately, this needs to be here due to cyclic imports + from telegram._giveaway import ( # pylint: disable=import-outside-toplevel + Giveaway, + GiveawayCompleted, + GiveawayCreated, + GiveawayWinners, + ) + from telegram._messageorigin import ( # pylint: disable=import-outside-toplevel + MessageOrigin, + ) + from telegram._reply import ( # pylint: disable=import-outside-toplevel + ExternalReplyInfo, + TextQuote, + ) + + data["giveaway"] = Giveaway.de_json(data.get("giveaway"), bot) + data["giveaway_completed"] = GiveawayCompleted.de_json(data.get("giveaway_completed"), bot) + data["giveaway_created"] = GiveawayCreated.de_json(data.get("giveaway_created"), bot) + data["giveaway_winners"] = GiveawayWinners.de_json(data.get("giveaway_winners"), bot) + data["link_preview_options"] = LinkPreviewOptions.de_json( + data.get("link_preview_options"), bot + ) + data["external_reply"] = ExternalReplyInfo.de_json(data.get("external_reply"), bot) + data["quote"] = TextQuote.de_json(data.get("quote"), bot) + data["forward_origin"] = MessageOrigin.de_json(data.get("forward_origin"), bot) + + return super().de_json(data=data, bot=bot) # type: ignore[return-value] @property def effective_attachment( @@ -1034,14 +1533,16 @@ def effective_attachment( return self._effective_attachment # type: ignore[return-value] - def _quote(self, quote: Optional[bool], reply_to_message_id: Optional[int]) -> Optional[int]: + def _quote( + self, quote: Optional[bool], reply_to_message_id: Optional[int] = None + ) -> Optional[ReplyParameters]: """Modify kwargs for replying with or without quoting.""" if reply_to_message_id is not None: - return reply_to_message_id + return ReplyParameters(reply_to_message_id) if quote is not None: if quote: - return self.message_id + return ReplyParameters(self.message_id) else: # Unfortunately we need some ExtBot logic here because it's hard to move shortcut @@ -1051,10 +1552,214 @@ def _quote(self, quote: Optional[bool], reply_to_message_id: Optional[int]) -> O else: default_quote = None if (default_quote is None and self.chat.type != Chat.PRIVATE) or default_quote: - return self.message_id + return ReplyParameters(self.message_id) return None + def compute_quote_position_and_entities( + self, quote: str, index: Optional[int] = None + ) -> Tuple[int, Optional[Tuple[MessageEntity, ...]]]: + """ + Use this function to compute position and entities of a quote in the message text or + caption. Useful for filling the parameters + :paramref:`~telegram.ReplyParameters.quote_position` and + :paramref:`~telegram.ReplyParameters.quote_entities` of :class:`telegram.ReplyParameters` + when replying to a message. + + Example: + + Given a message with the text ``"Hello, world! Hello, world!"``, the following code + will return the position and entities of the second occurrence of ``"Hello, world!"``. + + .. code-block:: python + + message.compute_quote_position_and_entities("Hello, world!", 1) + + .. versionadded:: NEXT.VERSION + + Args: + quote (:obj:`str`): Part of the message which is to be quoted. This is + expected to have plain text without formatting entities. + index (:obj:`int`, optional): 0-based index of the occurrence of the quote in the + message. If not specified, the first occurrence is used. + + Returns: + Tuple[:obj:`int`, :obj:`None` | Tuple[:class:`~telegram.MessageEntity`, ...]]: On + success, a tuple containing information about quote position and entities is returned. + + Raises: + RuntimeError: If the message has neither :attr:`text` nor :attr:`caption`. + ValueError: If the requested index of quote doesn't exist in the message. + """ + if not (text := (self.text or self.caption)): + raise RuntimeError("This message has neither text nor caption.") + + # Telegram wants the position in UTF-16 code units, so we have to calculate in that space + utf16_text = text.encode("utf-16-le") + utf16_quote = quote.encode("utf-16-le") + effective_index = index or 0 + + matches = list(re.finditer(re.escape(utf16_quote), utf16_text)) + if (length := len(matches)) < effective_index + 1: + raise ValueError( + f"You requested the {index}-th occurrence of '{quote}', but this text appears " + f"only {length} times." + ) + + position = len(utf16_text[: matches[effective_index].start()]) // 2 + length = len(utf16_quote) // 2 + end_position = position + length + + entities = [] + for entity in self.entities or self.caption_entities: + if position <= entity.offset + entity.length and entity.offset <= end_position: + # shift the offset by the position of the quote + offset = max(0, entity.offset - position) + # trim the entity length to the length of the overlap with the quote + e_length = min(end_position, entity.offset + entity.length) - max( + position, entity.offset + ) + if e_length <= 0: + continue + + # create a new entity with the correct offset and length + # looping over slots rather manually accessing the attributes + # is more future-proof + kwargs = {attr: getattr(entity, attr) for attr in entity.__slots__} + kwargs["offset"] = offset + kwargs["length"] = e_length + entities.append(MessageEntity(**kwargs)) + + return position, tuple(entities) or None + + def build_reply_arguments( + self, + quote: Optional[str] = None, + quote_index: Optional[int] = None, + target_chat_id: Optional[Union[int, str]] = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: Optional[int] = None, + ) -> _ReplyKwargs: + """ + Builds a dictionary with the keys ``chat_id`` and ``reply_parameters``. This dictionary can + be used to reply to a message with the given quote and target chat. + + Examples: + + Usage with :meth:`telegram.Bot.send_message`: + + .. code-block:: python + + await bot.send_message( + text="This is a reply", + **message.build_reply_arguments(quote="Quoted Text") + ) + + Usage with :meth:`reply_text`, replying in the same chat: + + .. code-block:: python + + await message.reply_text( + "This is a reply", + do_quote=message.build_reply_arguments(quote="Quoted Text") + ) + + Usage with :meth:`reply_text`, replying in a different chat: + + .. code-block:: python + + await message.reply_text( + "This is a reply", + do_quote=message.build_reply_arguments( + quote="Quoted Text", + target_chat_id=-100123456789 + ) + ) + + .. versionadded:: NEXT.VERSION + + Args: + quote (:obj:`str`, optional): Passed in :meth:`compute_quote_position_and_entities` + as parameter :paramref:`~compute_quote_position_and_entities.quote` to compute + quote entities. Defaults to :obj:`None`. + quote_index (:obj:`int`, optional): Passed in + :meth:`compute_quote_position_and_entities` as parameter + :paramref:`~compute_quote_position_and_entities.quote_index` to compute quote + position. Defaults to :obj:`None`. + target_chat_id (:obj:`int` | :obj:`str`, optional): |chat_id_channel| + Defaults to :attr:`chat_id`. + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Will be applied only if the reply happens in the same chat and forum topic. + message_thread_id (:obj:`int`, optional): |message_thread_id| + + Returns: + :obj:`dict`: + """ + target_chat_is_self = target_chat_id in (None, self.chat_id, f"@{self.chat.username}") + + if target_chat_is_self and message_thread_id in ( + None, + self.message_thread_id, + ): + # defaults handling will take place in `Bot._insert_defaults` + effective_aswr: ODVInput[bool] = allow_sending_without_reply + else: + effective_aswr = None + + quote_position, quote_entities = ( + self.compute_quote_position_and_entities(quote, quote_index) if quote else (None, None) + ) + return { # type: ignore[typeddict-item] + "reply_parameters": ReplyParameters( + chat_id=None if target_chat_is_self else self.chat_id, + message_id=self.message_id, + quote=quote, + quote_position=quote_position, + quote_entities=quote_entities, + allow_sending_without_reply=effective_aswr, + ), + "chat_id": target_chat_id or self.chat_id, + } + + async def _parse_quote_arguments( + self, + do_quote: Optional[Union[bool, _ReplyKwargs]], + quote: Optional[bool], + reply_to_message_id: Optional[int], + reply_parameters: Optional["ReplyParameters"], + ) -> Tuple[Union[str, int], ReplyParameters]: + if quote and do_quote: + raise ValueError("The arguments `quote` and `do_quote` are mutually exclusive") + + if reply_to_message_id is not None and reply_parameters is not None: + raise ValueError( + "`reply_to_message_id` and `reply_parameters` are mutually exclusive." + ) + + if quote is not None: + warn( + "The `quote` parameter is deprecated in favor of the `do_quote` parameter. Please " + "update your code to use `do_quote` instead.", + PTBDeprecationWarning, + stacklevel=2, + ) + + effective_do_quote = quote or do_quote + chat_id: Union[str, int] = self.chat_id + + # reply_parameters and reply_to_message_id overrule the do_quote parameter + if reply_parameters is not None: + effective_reply_parameters = reply_parameters + elif reply_to_message_id is not None: + effective_reply_parameters = ReplyParameters(message_id=reply_to_message_id) + elif isinstance(effective_do_quote, dict): + effective_reply_parameters = effective_do_quote["reply_parameters"] + chat_id = effective_do_quote["chat_id"] + else: + effective_reply_parameters = self._quote(effective_do_quote) + + return chat_id, effective_reply_parameters + async def reply_text( self, text: str, @@ -1067,8 +1772,11 @@ async def reply_text( entities: Optional[Sequence["MessageEntity"]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + reply_parameters: Optional["ReplyParameters"] = None, *, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1082,22 +1790,30 @@ async def reply_text( For the documentation of the arguments, please see :meth:`telegram.Bot.send_message`. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the message is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, instance representing the message posted. """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().send_message( - chat_id=self.chat_id, + chat_id=chat_id, text=text, parse_mode=parse_mode, disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, reply_markup=reply_markup, allow_sending_without_reply=allow_sending_without_reply, entities=entities, @@ -1121,8 +1837,11 @@ async def reply_markdown( entities: Optional[Sequence["MessageEntity"]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + reply_parameters: Optional["ReplyParameters"] = None, *, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1147,21 +1866,29 @@ async def reply_markdown( Telegram for backward compatibility. You should use :meth:`reply_markdown_v2` instead. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the message is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, instance representing the message posted. """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().send_message( - chat_id=self.chat_id, + chat_id=chat_id, text=text, parse_mode=ParseMode.MARKDOWN, disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, reply_markup=reply_markup, allow_sending_without_reply=allow_sending_without_reply, entities=entities, @@ -1185,8 +1912,11 @@ async def reply_markdown_v2( entities: Optional[Sequence["MessageEntity"]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + reply_parameters: Optional["ReplyParameters"] = None, *, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1207,21 +1937,29 @@ async def reply_markdown_v2( For the documentation of the arguments, please see :meth:`telegram.Bot.send_message`. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the message is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, instance representing the message posted. """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().send_message( - chat_id=self.chat_id, + chat_id=chat_id, text=text, parse_mode=ParseMode.MARKDOWN_V2, disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, reply_markup=reply_markup, allow_sending_without_reply=allow_sending_without_reply, entities=entities, @@ -1245,8 +1983,11 @@ async def reply_html( entities: Optional[Sequence["MessageEntity"]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + reply_parameters: Optional["ReplyParameters"] = None, *, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1267,21 +2008,29 @@ async def reply_html( For the documentation of the arguments, please see :meth:`telegram.Bot.send_message`. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the message is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, instance representing the message posted. """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().send_message( - chat_id=self.chat_id, + chat_id=chat_id, text=text, parse_mode=ParseMode.HTML, disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, reply_markup=reply_markup, allow_sending_without_reply=allow_sending_without_reply, entities=entities, @@ -1304,8 +2053,10 @@ async def reply_media_group( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1322,10 +2073,14 @@ async def reply_media_group( For the documentation of the arguments, please see :meth:`telegram.Bot.send_media_group`. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the media group is sent as an - actual reply to this message. If ``reply_to_message_id`` is passed, this parameter - will be ignored. Default: :obj:`True` in group chats and :obj:`False` in private - chats. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION Returns: Tuple[:class:`telegram.Message`]: An array of the sent Messages. @@ -1333,12 +2088,14 @@ async def reply_media_group( Raises: :class:`telegram.error.TelegramError` """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().send_media_group( - chat_id=self.chat_id, + chat_id=chat_id, media=media, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -1365,9 +2122,11 @@ async def reply_photo( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, has_spoiler: Optional[bool] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1381,21 +2140,28 @@ async def reply_photo( For the documentation of the arguments, please see :meth:`telegram.Bot.send_photo`. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the photo is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, instance representing the message posted. """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().send_photo( - chat_id=self.chat_id, + chat_id=chat_id, photo=photo, caption=caption, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, reply_markup=reply_markup, parse_mode=parse_mode, allow_sending_without_reply=allow_sending_without_reply, @@ -1427,9 +2193,11 @@ async def reply_audio( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1443,24 +2211,31 @@ async def reply_audio( For the documentation of the arguments, please see :meth:`telegram.Bot.send_audio`. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the audio is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, instance representing the message posted. """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().send_audio( - chat_id=self.chat_id, + chat_id=chat_id, audio=audio, duration=duration, performer=performer, title=title, caption=caption, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, reply_markup=reply_markup, parse_mode=parse_mode, allow_sending_without_reply=allow_sending_without_reply, @@ -1490,9 +2265,11 @@ async def reply_document( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1506,22 +2283,29 @@ async def reply_document( For the documentation of the arguments, please see :meth:`telegram.Bot.send_document`. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the document is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, instance representing the message posted. """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().send_document( - chat_id=self.chat_id, + chat_id=chat_id, document=document, filename=filename, caption=caption, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1554,9 +2338,11 @@ async def reply_animation( message_thread_id: Optional[int] = None, has_spoiler: Optional[bool] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1570,18 +2356,24 @@ async def reply_animation( For the documentation of the arguments, please see :meth:`telegram.Bot.send_animation`. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the animation is sent as an - actual reply to this message. If ``reply_to_message_id`` is passed, this parameter - will be ignored. Default: :obj:`True` in group chats and :obj:`False` in private - chats. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, instance representing the message posted. """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().send_animation( - chat_id=self.chat_id, + chat_id=chat_id, animation=animation, duration=duration, width=width, @@ -1589,7 +2381,7 @@ async def reply_animation( caption=caption, parse_mode=parse_mode, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1615,8 +2407,10 @@ async def reply_sticker( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, emoji: Optional[str] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1630,20 +2424,27 @@ async def reply_sticker( For the documentation of the arguments, please see :meth:`telegram.Bot.send_sticker`. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the sticker is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, instance representing the message posted. """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().send_sticker( - chat_id=self.chat_id, + chat_id=chat_id, sticker=sticker, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1674,9 +2475,11 @@ async def reply_video( message_thread_id: Optional[int] = None, has_spoiler: Optional[bool] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1690,22 +2493,29 @@ async def reply_video( For the documentation of the arguments, please see :meth:`telegram.Bot.send_video`. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the video is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, instance representing the message posted. """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().send_video( - chat_id=self.chat_id, + chat_id=chat_id, video=video, duration=duration, caption=caption, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1737,9 +2547,11 @@ async def reply_video_note( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1753,23 +2565,29 @@ async def reply_video_note( For the documentation of the arguments, please see :meth:`telegram.Bot.send_video_note`. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the video note is sent as an - actual reply to this message. If ``reply_to_message_id`` is passed, this parameter - will be ignored. Default: :obj:`True` in group chats and :obj:`False` in private - chats. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, instance representing the message posted. """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().send_video_note( - chat_id=self.chat_id, + chat_id=chat_id, video_note=video_note, duration=duration, length=length, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1796,9 +2614,11 @@ async def reply_voice( caption_entities: Optional[Sequence["MessageEntity"]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1812,23 +2632,29 @@ async def reply_voice( For the documentation of the arguments, please see :meth:`telegram.Bot.send_voice`. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the voice note is sent as an - actual reply to this message. If ``reply_to_message_id`` is passed, this parameter - will be ignored. Default: :obj:`True` in group chats and :obj:`False` in private - chats. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, instance representing the message posted. """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().send_voice( - chat_id=self.chat_id, + chat_id=chat_id, voice=voice, duration=duration, caption=caption, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1857,9 +2683,11 @@ async def reply_location( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, location: Optional[Location] = None, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1873,21 +2701,28 @@ async def reply_location( For the documentation of the arguments, please see :meth:`telegram.Bot.send_location`. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the location is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, instance representing the message posted. """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().send_location( - chat_id=self.chat_id, + chat_id=chat_id, latitude=latitude, longitude=longitude, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1920,9 +2755,11 @@ async def reply_venue( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, venue: Optional[Venue] = None, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1936,24 +2773,31 @@ async def reply_venue( For the documentation of the arguments, please see :meth:`telegram.Bot.send_venue`. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the venue is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, instance representing the message posted. """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().send_venue( - chat_id=self.chat_id, + chat_id=chat_id, latitude=latitude, longitude=longitude, title=title, address=address, foursquare_id=foursquare_id, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1981,9 +2825,11 @@ async def reply_contact( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, contact: Optional[Contact] = None, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1997,22 +2843,29 @@ async def reply_contact( For the documentation of the arguments, please see :meth:`telegram.Bot.send_contact`. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the contact is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, instance representing the message posted. """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().send_contact( - chat_id=self.chat_id, + chat_id=chat_id, phone_number=phone_number, first_name=first_name, last_name=last_name, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2046,8 +2899,10 @@ async def reply_poll( explanation_entities: Optional[Sequence["MessageEntity"]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2061,17 +2916,24 @@ async def reply_poll( For the documentation of the arguments, please see :meth:`telegram.Bot.send_poll`. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the poll is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, instance representing the message posted. """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().send_poll( - chat_id=self.chat_id, + chat_id=chat_id, question=question, options=options, is_anonymous=is_anonymous, @@ -2080,7 +2942,7 @@ async def reply_poll( correct_option_id=correct_option_id, is_closed=is_closed, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2106,8 +2968,10 @@ async def reply_dice( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2121,19 +2985,26 @@ async def reply_dice( For the documentation of the arguments, please see :meth:`telegram.Bot.send_dice`. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the dice is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, instance representing the message posted. """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().send_dice( - chat_id=self.chat_id, + chat_id=chat_id, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2189,8 +3060,10 @@ async def reply_game( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2204,9 +3077,14 @@ async def reply_game( For the documentation of the arguments, please see :meth:`telegram.Bot.send_game`. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the game is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION .. versionadded:: 13.2 @@ -2214,12 +3092,14 @@ async def reply_game( :class:`telegram.Message`: On success, instance representing the message posted. """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().send_game( - chat_id=self.chat_id, + chat_id=chat_id, game_short_name=game_short_name, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2260,8 +3140,10 @@ async def reply_invoice( suggested_tip_amounts: Optional[Sequence[int]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2287,17 +3169,24 @@ async def reply_invoice( :paramref:`start_parameter ` is optional. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the invoice is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.Message`: On success, instance representing the message posted. """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().send_invoice( - chat_id=self.chat_id, + chat_id=chat_id, title=title, description=description, payload=payload, @@ -2315,7 +3204,7 @@ async def reply_invoice( need_shipping_address=need_shipping_address, is_flexible=is_flexible, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, reply_markup=reply_markup, provider_data=provider_data, send_phone_number_to_provider=send_phone_number_to_provider, @@ -2394,6 +3283,7 @@ async def copy( reply_markup: Optional[ReplyMarkup] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2426,6 +3316,7 @@ async def copy( caption_entities=caption_entities, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, read_timeout=read_timeout, @@ -2450,8 +3341,10 @@ async def reply_copy( reply_markup: Optional[ReplyMarkup] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, quote: Optional[bool] = None, + do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2470,26 +3363,32 @@ async def reply_copy( For the documentation of the arguments, please see :meth:`telegram.Bot.copy_message`. Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the copy is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + quote (:obj:`bool`, optional): |reply_quote| .. versionadded:: 13.1 + .. deprecated:: NEXT.VERSION + This argument is deprecated in favor of :paramref:`do_quote` + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + Mutually exclusive with :paramref:`quote`. + + .. versionadded:: NEXT.VERSION Returns: :class:`telegram.MessageId`: On success, returns the MessageId of the sent message. """ - reply_to_message_id = self._quote(quote, reply_to_message_id) + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, quote, reply_to_message_id, reply_parameters + ) return await self.get_bot().copy_message( - chat_id=self.chat_id, + chat_id=chat_id, from_chat_id=from_chat_id, message_id=message_id, caption=caption, parse_mode=parse_mode, caption_entities=caption_entities, disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, + reply_parameters=effective_reply_parameters, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, read_timeout=read_timeout, @@ -2508,6 +3407,7 @@ async def edit_text( disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, reply_markup: Optional["InlineKeyboardMarkup"] = None, entities: Optional[Sequence["MessageEntity"]] = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2539,6 +3439,7 @@ async def edit_text( text=text, parse_mode=parse_mode, disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -3158,6 +4059,44 @@ async def unpin_all_forum_topic_messages( api_kwargs=api_kwargs, ) + async def set_reaction( + self, + reaction: Optional[ + Union[Sequence["ReactionType"], "ReactionType", Sequence[str], str] + ] = None, + is_big: Optional[bool] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + ) -> bool: + """Shortcut for:: + + await bot.set_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.set_message_reaction`. + + .. versionadded:: NEXT.VERSION + + Returns: + :obj:`bool` On success, :obj:`True` is returned. + """ + return await self.get_bot().set_message_reaction( + chat_id=self.chat_id, + message_id=self.message_id, + reaction=reaction, + is_big=is_big, + 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`. @@ -3274,8 +4213,9 @@ def parse_caption_entities( if entity.type in types } - @staticmethod + @classmethod def _parse_html( + cls, message_text: Optional[str], entities: Dict[MessageEntity, str], urled: bool = False, @@ -3284,8 +4224,7 @@ def _parse_html( if message_text is None: return None - message_text = message_text.encode("utf-16-le") # type: ignore - + utf_16_text = message_text.encode("utf-16-le") html_text = "" last_offset = 0 @@ -3293,81 +4232,69 @@ def _parse_html( parsed_entities = [] for entity, text in sorted_entities: - if entity not in parsed_entities: - nested_entities = { - e: t - for (e, t) in sorted_entities - if e.offset >= entity.offset - and e.offset + e.length <= entity.offset + entity.length - and e != entity - } - parsed_entities.extend(list(nested_entities.keys())) - - orig_text = text + if entity in parsed_entities: + continue + + nested_entities = { + e: t + for (e, t) in sorted_entities + if e.offset >= entity.offset + and e.offset + e.length <= entity.offset + entity.length + and e != entity + } + parsed_entities.extend(list(nested_entities.keys())) + + if nested_entities: + escaped_text = cls._parse_html( + text, nested_entities, urled=urled, offset=entity.offset + ) + else: escaped_text = escape(text) - if nested_entities: - escaped_text = Message._parse_html( - orig_text, nested_entities, urled=urled, offset=entity.offset - ) - - if entity.type == MessageEntity.TEXT_LINK: - insert = f'{escaped_text}' - elif entity.type == MessageEntity.TEXT_MENTION and entity.user: - insert = f'{escaped_text}' - elif entity.type == MessageEntity.URL and urled: - insert = f'{escaped_text}' - elif entity.type == MessageEntity.BOLD: - insert = f"{escaped_text}" - elif entity.type == MessageEntity.ITALIC: - insert = f"{escaped_text}" - elif entity.type == MessageEntity.CODE: - insert = f"{escaped_text}" - elif entity.type == MessageEntity.PRE: - if entity.language: - insert = ( - f'
{escaped_text}
' - ) - else: - insert = f"
{escaped_text}
" - elif entity.type == MessageEntity.UNDERLINE: - insert = f"{escaped_text}" - elif entity.type == MessageEntity.STRIKETHROUGH: - insert = f"{escaped_text}" - elif entity.type == MessageEntity.SPOILER: - insert = f'{escaped_text}' - elif entity.type == MessageEntity.CUSTOM_EMOJI: - insert = ( - f'{escaped_text}' - ) - else: - insert = escaped_text - - if offset == 0: - html_text += ( - escape( - message_text[ # type: ignore - last_offset * 2 : (entity.offset - offset) * 2 - ].decode("utf-16-le") - ) - + insert - ) + if entity.type == MessageEntity.TEXT_LINK: + insert = f'{escaped_text}' + elif entity.type == MessageEntity.TEXT_MENTION and entity.user: + insert = f'{escaped_text}' + elif entity.type == MessageEntity.URL and urled: + insert = f'{escaped_text}' + elif entity.type == MessageEntity.BLOCKQUOTE: + insert = f"
{escaped_text}
" + elif entity.type == MessageEntity.BOLD: + insert = f"{escaped_text}" + elif entity.type == MessageEntity.ITALIC: + insert = f"{escaped_text}" + elif entity.type == MessageEntity.CODE: + insert = f"{escaped_text}" + elif entity.type == MessageEntity.PRE: + if entity.language: + insert = f'
{escaped_text}
' else: - html_text += ( - message_text[ # type: ignore - last_offset * 2 : (entity.offset - offset) * 2 - ].decode("utf-16-le") - + insert - ) + insert = f"
{escaped_text}
" + elif entity.type == MessageEntity.UNDERLINE: + insert = f"{escaped_text}" + elif entity.type == MessageEntity.STRIKETHROUGH: + insert = f"{escaped_text}" + elif entity.type == MessageEntity.SPOILER: + insert = f'{escaped_text}' + elif entity.type == MessageEntity.CUSTOM_EMOJI: + insert = f'{escaped_text}' + else: + insert = escaped_text + + # Make sure to escape the text that is not part of the entity + # if we're in a nested entity, this is still required, since in that case this + # text is part of the parent entity + html_text += ( + escape( + utf_16_text[last_offset * 2 : (entity.offset - offset) * 2].decode("utf-16-le") + ) + + insert + ) - last_offset = entity.offset - offset + entity.length + last_offset = entity.offset - offset + entity.length - if offset == 0: - html_text += escape( - message_text[last_offset * 2 :].decode("utf-16-le") # type: ignore - ) - else: - html_text += message_text[last_offset * 2 :].decode("utf-16-le") # type: ignore + # see comment above + html_text += escape(utf_16_text[last_offset * 2 :].decode("utf-16-le")) return html_text @@ -3378,12 +4305,18 @@ def text_html(self) -> str: Use this if you want to retrieve the message text with the entities formatted as HTML in the same way the original message was formatted. + Warning: + |text_html| + .. versionchanged:: 13.10 Spoiler entities are now formatted as HTML. .. versionchanged:: 20.3 Custom emoji entities are now supported. + .. versionchanged:: NEXT.VERSION + Blockquote entities are now supported. + Returns: :obj:`str`: Message text with entities formatted as HTML. @@ -3397,12 +4330,18 @@ def text_html_urled(self) -> str: Use this if you want to retrieve the message text with the entities formatted as HTML. This also formats :attr:`telegram.MessageEntity.URL` as a hyperlink. + Warning: + |text_html| + .. versionchanged:: 13.10 Spoiler entities are now formatted as HTML. .. versionchanged:: 20.3 Custom emoji entities are now supported. + .. versionchanged:: NEXT.VERSION + Blockquote entities are now supported. + Returns: :obj:`str`: Message text with entities formatted as HTML. @@ -3417,12 +4356,18 @@ def caption_html(self) -> str: Use this if you want to retrieve the message caption with the caption entities formatted as HTML in the same way the original message was formatted. + Warning: + |text_html| + .. versionchanged:: 13.10 Spoiler entities are now formatted as HTML. .. versionchanged:: 20.3 Custom emoji entities are now supported. + .. versionchanged:: NEXT.VERSION + Blockquote entities are now supported. + Returns: :obj:`str`: Message caption with caption entities formatted as HTML. """ @@ -3436,32 +4381,48 @@ def caption_html_urled(self) -> str: Use this if you want to retrieve the message caption with the caption entities formatted as HTML. This also formats :attr:`telegram.MessageEntity.URL` as a hyperlink. + Warning: + |text_html| + .. versionchanged:: 13.10 Spoiler entities are now formatted as HTML. .. versionchanged:: 20.3 Custom emoji entities are now supported. + .. versionchanged:: NEXT.VERSION + Blockquote entities are now supported. + Returns: :obj:`str`: Message caption with caption entities formatted as HTML. """ return self._parse_html(self.caption, self.parse_caption_entities(), urled=True) - @staticmethod + @classmethod def _parse_markdown( + cls, message_text: Optional[str], entities: Dict[MessageEntity, str], urled: bool = False, version: MarkdownVersion = 1, offset: int = 0, ) -> Optional[str]: - version = int(version) # type: ignore + if version == 1: + for entity_type in ( + MessageEntity.UNDERLINE, + MessageEntity.STRIKETHROUGH, + MessageEntity.SPOILER, + MessageEntity.BLOCKQUOTE, + MessageEntity.CUSTOM_EMOJI, + ): + if any(entity.type == entity_type for entity in entities): + name = entity_type.name.title().replace("_", " ") # type:ignore[attr-defined] + raise ValueError(f"{name} entities are not supported for Markdown version 1") if message_text is None: return None - message_text = message_text.encode("utf-16-le") # type: ignore - + utf_16_text = message_text.encode("utf-16-le") markdown_text = "" last_offset = 0 @@ -3469,125 +4430,103 @@ def _parse_markdown( parsed_entities = [] for entity, text in sorted_entities: - if entity not in parsed_entities: - nested_entities = { - e: t - for (e, t) in sorted_entities - if e.offset >= entity.offset - and e.offset + e.length <= entity.offset + entity.length - and e != entity - } - parsed_entities.extend(list(nested_entities.keys())) - + if entity in parsed_entities: + continue + + nested_entities = { + e: t + for (e, t) in sorted_entities + if e.offset >= entity.offset + and e.offset + e.length <= entity.offset + entity.length + and e != entity + } + parsed_entities.extend(list(nested_entities.keys())) + + if nested_entities: + if version < 2: + raise ValueError("Nested entities are not supported for Markdown version 1") + + escaped_text = cls._parse_markdown( + text, + nested_entities, + urled=urled, + offset=entity.offset, + version=version, + ) + else: escaped_text = escape_markdown(text, version=version) - if nested_entities: - if version < 2: - raise ValueError( - "Nested entities are not supported for Markdown version 1" - ) - - escaped_text = Message._parse_markdown( - text, - nested_entities, - urled=urled, - offset=entity.offset, - version=version, - ) - - if entity.type == MessageEntity.TEXT_LINK: - if version == 1: - url = entity.url - else: - # Links need special escaping. Also can't have entities nested within - url = escape_markdown( - entity.url, version=version, entity_type=MessageEntity.TEXT_LINK - ) - insert = f"[{escaped_text}]({url})" - elif entity.type == MessageEntity.TEXT_MENTION and entity.user: - insert = f"[{escaped_text}](tg://user?id={entity.user.id})" - elif entity.type == MessageEntity.URL and urled: - link = text if version == 1 else escaped_text - insert = f"[{link}]({text})" - elif entity.type == MessageEntity.BOLD: - insert = f"*{escaped_text}*" - elif entity.type == MessageEntity.ITALIC: - insert = f"_{escaped_text}_" - elif entity.type == MessageEntity.CODE: - # Monospace needs special escaping. Also can't have entities nested within - insert = f"`{escape_markdown(text, version, MessageEntity.CODE)}`" - - elif entity.type == MessageEntity.PRE: - # Monospace needs special escaping. Also can't have entities nested within - code = escape_markdown(text, version=version, entity_type=MessageEntity.PRE) - if entity.language: - prefix = f"```{entity.language}\n" - elif code.startswith("\\"): - prefix = "```" - else: - prefix = "```\n" - insert = f"{prefix}{code}```" - elif entity.type == MessageEntity.UNDERLINE: - if version == 1: - raise ValueError( - "Underline entities are not supported for Markdown version 1" - ) - insert = f"__{escaped_text}__" - elif entity.type == MessageEntity.STRIKETHROUGH: - if version == 1: - raise ValueError( - "Strikethrough entities are not supported for Markdown version 1" - ) - insert = f"~{escaped_text}~" - elif entity.type == MessageEntity.SPOILER: - if version == 1: - raise ValueError( - "Spoiler entities are not supported for Markdown version 1" - ) - insert = f"||{escaped_text}||" - elif entity.type == MessageEntity.CUSTOM_EMOJI: - if version == 1: - raise ValueError( - "Custom emoji entities are not supported for Markdown version 1" - ) - # This should never be needed because ids are numeric but the documentation - # specifically mentions it so here we are - custom_emoji_id = escape_markdown( - entity.custom_emoji_id, - version=version, - entity_type=MessageEntity.CUSTOM_EMOJI, - ) - insert = f"![{escaped_text}](tg://emoji?id={custom_emoji_id})" + if entity.type == MessageEntity.TEXT_LINK: + if version == 1: + url = entity.url else: - insert = escaped_text - - if offset == 0: - markdown_text += ( - escape_markdown( - message_text[ # type: ignore - last_offset * 2 : (entity.offset - offset) * 2 - ].decode("utf-16-le"), - version=version, - ) - + insert + # Links need special escaping. Also can't have entities nested within + url = escape_markdown( + entity.url, version=version, entity_type=MessageEntity.TEXT_LINK ) + insert = f"[{escaped_text}]({url})" + elif entity.type == MessageEntity.TEXT_MENTION and entity.user: + insert = f"[{escaped_text}](tg://user?id={entity.user.id})" + elif entity.type == MessageEntity.URL and urled: + link = text if version == 1 else escaped_text + insert = f"[{link}]({text})" + elif entity.type == MessageEntity.BOLD: + insert = f"*{escaped_text}*" + elif entity.type == MessageEntity.ITALIC: + insert = f"_{escaped_text}_" + elif entity.type == MessageEntity.CODE: + # Monospace needs special escaping. Also can't have entities nested within + insert = f"`{escape_markdown(text, version, MessageEntity.CODE)}`" + elif entity.type == MessageEntity.PRE: + # Monospace needs special escaping. Also can't have entities nested within + code = escape_markdown(text, version=version, entity_type=MessageEntity.PRE) + if entity.language: + prefix = f"```{entity.language}\n" + elif code.startswith("\\"): + prefix = "```" else: - markdown_text += ( - message_text[ # type: ignore - last_offset * 2 : (entity.offset - offset) * 2 - ].decode("utf-16-le") - + insert - ) + prefix = "```\n" + insert = f"{prefix}{code}```" + elif entity.type == MessageEntity.UNDERLINE: + insert = f"__{escaped_text}__" + elif entity.type == MessageEntity.STRIKETHROUGH: + insert = f"~{escaped_text}~" + elif entity.type == MessageEntity.SPOILER: + insert = f"||{escaped_text}||" + elif entity.type == MessageEntity.BLOCKQUOTE: + insert = ">" + "\n>".join(escaped_text.splitlines()) + elif entity.type == MessageEntity.CUSTOM_EMOJI: + # This should never be needed because ids are numeric but the documentation + # specifically mentions it so here we are + custom_emoji_id = escape_markdown( + entity.custom_emoji_id, + version=version, + entity_type=MessageEntity.CUSTOM_EMOJI, + ) + insert = f"![{escaped_text}](tg://emoji?id={custom_emoji_id})" + else: + insert = escaped_text + + # Make sure to escape the text that is not part of the entity + # if we're in a nested entity, this is still required, since in that case this + # text is part of the parent entity + markdown_text += ( + escape_markdown( + utf_16_text[last_offset * 2 : (entity.offset - offset) * 2].decode( + "utf-16-le" + ), + version=version, + ) + + insert + ) - last_offset = entity.offset - offset + entity.length + last_offset = entity.offset - offset + entity.length - if offset == 0: - markdown_text += escape_markdown( - message_text[last_offset * 2 :].decode("utf-16-le"), # type: ignore - version=version, - ) - else: - markdown_text += message_text[last_offset * 2 :].decode("utf-16-le") # type: ignore + # see comment above + markdown_text += escape_markdown( + utf_16_text[last_offset * 2 :].decode("utf-16-le"), + version=version, + ) return markdown_text @@ -3599,22 +4538,25 @@ def text_markdown(self) -> str: Use this if you want to retrieve the message text with the entities formatted as Markdown in the same way the original message was formatted. - Note: - * :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by - Telegram for backward compatibility. You should use - :meth:`text_markdown_v2` instead. + Warning: + |text_markdown| - * |custom_emoji_formatting_note| + Note: + :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by + Telegram for backward compatibility. You should use :meth:`text_markdown_v2` instead. .. versionchanged:: 20.5 |custom_emoji_no_md1_support| + .. versionchanged:: NEXT.VERSION + |blockquote_no_md1_support| + Returns: :obj:`str`: Message text with entities formatted as Markdown. Raises: - :exc:`ValueError`: If the message contains underline, strikethrough, spoiler or nested - entities. + :exc:`ValueError`: If the message contains underline, strikethrough, spoiler, + blockquote or nested entities. """ return self._parse_markdown(self.text, self.parse_entities(), urled=False) @@ -3627,12 +4569,18 @@ def text_markdown_v2(self) -> str: Use this if you want to retrieve the message text with the entities formatted as Markdown in the same way the original message was formatted. + Warning: + |text_markdown| + .. versionchanged:: 13.10 Spoiler entities are now formatted as Markdown V2. .. versionchanged:: 20.3 Custom emoji entities are now supported. + .. versionchanged:: NEXT.VERSION + Blockquote entities are now supported. + Returns: :obj:`str`: Message text with entities formatted as Markdown. """ @@ -3646,22 +4594,26 @@ def text_markdown_urled(self) -> str: Use this if you want to retrieve the message text with the entities formatted as Markdown. This also formats :attr:`telegram.MessageEntity.URL` as a hyperlink. - Note: - * :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by - Telegram for backward compatibility. You should use :meth:`text_markdown_v2_urled` - instead. + Warning: + |text_markdown| - * |custom_emoji_formatting_note| + Note: + :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by + Telegram for backward compatibility. You should use :meth:`text_markdown_v2_urled` + instead. .. versionchanged:: 20.5 |custom_emoji_no_md1_support| + .. versionchanged:: NEXT.VERSION + |blockquote_no_md1_support| + Returns: :obj:`str`: Message text with entities formatted as Markdown. Raises: - :exc:`ValueError`: If the message contains underline, strikethrough, spoiler or nested - entities. + :exc:`ValueError`: If the message contains underline, strikethrough, spoiler, + blockquote or nested entities. """ return self._parse_markdown(self.text, self.parse_entities(), urled=True) @@ -3674,12 +4626,18 @@ def text_markdown_v2_urled(self) -> str: Use this if you want to retrieve the message text with the entities formatted as Markdown. This also formats :attr:`telegram.MessageEntity.URL` as a hyperlink. + Warning: + |text_markdown| + .. versionchanged:: 13.10 Spoiler entities are now formatted as Markdown V2. .. versionchanged:: 20.3 Custom emoji entities are now supported. + .. versionchanged:: NEXT.VERSION + Blockquote entities are now supported. + Returns: :obj:`str`: Message text with entities formatted as Markdown. """ @@ -3693,22 +4651,24 @@ def caption_markdown(self) -> str: Use this if you want to retrieve the message caption with the caption entities formatted as Markdown in the same way the original message was formatted. - Note: - * :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by - Telegram for backward compatibility. You should use :meth:`caption_markdown_v2` - instead. - - * |custom_emoji_formatting_note| + Warning: + |text_markdown| + Note: + :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by + Telegram for backward compatibility. You should use :meth:`caption_markdown_v2` .. versionchanged:: 20.5 |custom_emoji_no_md1_support| + .. versionchanged:: NEXT.VERSION + |blockquote_no_md1_support| + Returns: :obj:`str`: Message caption with caption entities formatted as Markdown. Raises: - :exc:`ValueError`: If the message contains underline, strikethrough, spoiler or nested - entities. + :exc:`ValueError`: If the message contains underline, strikethrough, spoiler, + blockquote or nested entities. """ return self._parse_markdown(self.caption, self.parse_caption_entities(), urled=False) @@ -3721,12 +4681,18 @@ def caption_markdown_v2(self) -> str: Use this if you want to retrieve the message caption with the caption entities formatted as Markdown in the same way the original message was formatted. + Warning: + |text_markdown| + .. versionchanged:: 13.10 Spoiler entities are now formatted as Markdown V2. .. versionchanged:: 20.3 Custom emoji entities are now supported. + .. versionchanged:: NEXT.VERSION + Blockquote entities are now supported. + Returns: :obj:`str`: Message caption with caption entities formatted as Markdown. """ @@ -3742,22 +4708,26 @@ def caption_markdown_urled(self) -> str: Use this if you want to retrieve the message caption with the caption entities formatted as Markdown. This also formats :attr:`telegram.MessageEntity.URL` as a hyperlink. - Note: - * :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by - Telegram for backward compatibility. You should use - :meth:`caption_markdown_v2_urled` instead. + Warning: + |text_markdown| - * |custom_emoji_formatting_note| + Note: + :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by + Telegram for backward compatibility. You should use + :meth:`caption_markdown_v2_urled` instead. .. versionchanged:: 20.5 |custom_emoji_no_md1_support| + .. versionchanged:: NEXT.VERSION + |blockquote_no_md1_support| + Returns: :obj:`str`: Message caption with caption entities formatted as Markdown. Raises: - :exc:`ValueError`: If the message contains underline, strikethrough, spoiler or nested - entities. + :exc:`ValueError`: If the message contains underline, strikethrough, spoiler, + blockquote or nested entities. """ return self._parse_markdown(self.caption, self.parse_caption_entities(), urled=True) @@ -3770,12 +4740,18 @@ def caption_markdown_v2_urled(self) -> str: Use this if you want to retrieve the message caption with the caption entities formatted as Markdown. This also formats :attr:`telegram.MessageEntity.URL` as a hyperlink. + Warning: + |text_markdown| + .. versionchanged:: 13.10 Spoiler entities are now formatted as Markdown V2. .. versionchanged:: 20.3 Custom emoji entities are now supported. + .. versionchanged:: NEXT.VERSION + Blockquote entities are now supported. + Returns: :obj:`str`: Message caption with caption entities formatted as Markdown. """ diff --git a/telegram/_messageentity.py b/telegram/_messageentity.py index 94db82c1cb0..f14d071edff 100644 --- a/telegram/_messageentity.py +++ b/telegram/_messageentity.py @@ -45,12 +45,15 @@ class MessageEntity(TelegramObject): :attr:`EMAIL` (do-not-reply@telegram.org), :attr:`PHONE_NUMBER` (+1-212-555-0123), :attr:`BOLD` (**bold text**), :attr:`ITALIC` (*italic text*), :attr:`UNDERLINE` (underlined text), :attr:`STRIKETHROUGH`, :attr:`SPOILER` (spoiler message), - :attr:`CODE` (monowidth string), :attr:`PRE` (monowidth block), :attr:`TEXT_LINK` - (for clickable text URLs), :attr:`TEXT_MENTION` (for users without usernames), - :attr:`CUSTOM_EMOJI` (for inline custom emoji stickers). + :attr:`BLOCKQUOTE` (block quotation), :attr:`CODE` (monowidth string), :attr:`PRE` + (monowidth block), :attr:`TEXT_LINK` (for clickable text URLs), :attr:`TEXT_MENTION` + (for users without usernames), :attr:`CUSTOM_EMOJI` (for inline custom emoji stickers). .. versionadded:: 20.0 Added inline custom emoji + + .. versionadded:: NEXT.VERSION + Added block quotation offset (:obj:`int`): Offset in UTF-16 code units to the start of the entity. length (:obj:`int`): Length of the entity in UTF-16 code units. url (:obj:`str`, optional): For :attr:`TEXT_LINK` only, url that will be opened after @@ -71,12 +74,15 @@ class MessageEntity(TelegramObject): :attr:`EMAIL` (do-not-reply@telegram.org), :attr:`PHONE_NUMBER` (+1-212-555-0123), :attr:`BOLD` (**bold text**), :attr:`ITALIC` (*italic text*), :attr:`UNDERLINE` (underlined text), :attr:`STRIKETHROUGH`, :attr:`SPOILER` (spoiler message), - :attr:`CODE` (monowidth string), :attr:`PRE` (monowidth block), :attr:`TEXT_LINK` - (for clickable text URLs), :attr:`TEXT_MENTION` (for users without usernames), - :attr:`CUSTOM_EMOJI` (for inline custom emoji stickers). + :attr:`BLOCKQUOTE` (block quotation), :attr:`CODE` (monowidth string), :attr:`PRE` + (monowidth block), :attr:`TEXT_LINK` (for clickable text URLs), :attr:`TEXT_MENTION` + (for users without usernames), :attr:`CUSTOM_EMOJI` (for inline custom emoji stickers). .. versionadded:: 20.0 Added inline custom emoji + + .. versionadded:: NEXT.VERSION + Added block quotation offset (:obj:`int`): Offset in UTF-16 code units to the start of the entity. length (:obj:`int`): Length of the entity in UTF-16 code units. url (:obj:`str`): Optional. For :attr:`TEXT_LINK` only, url that will be opened after @@ -174,5 +180,10 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["MessageEntit .. versionadded:: 20.0 """ + BLOCKQUOTE: Final[str] = constants.MessageEntityType.BLOCKQUOTE + """:const:`telegram.constants.MessageEntityType.BLOCKQUOTE` + + .. versionadded:: NEXT.VERSION + """ ALL_TYPES: Final[List[str]] = list(constants.MessageEntityType) """List[:obj:`str`]: A list of all available message entity types.""" diff --git a/telegram/_messageorigin.py b/telegram/_messageorigin.py new file mode 100644 index 00000000000..75af6b663d3 --- /dev/null +++ b/telegram/_messageorigin.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# 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 the classes that represent Telegram MessageOigin.""" +import datetime +from typing import TYPE_CHECKING, Dict, Final, Optional, Type + +from telegram import constants +from telegram._chat import Chat +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils import enum +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class MessageOrigin(TelegramObject): + """ + Base class for telegram MessageOrigin object, it can be one of: + + * :class:`MessageOriginUser` + * :class:`MessageOriginHiddenUser` + * :class:`MessageOriginChat` + * :class:`MessageOriginChannel` + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`type` and :attr:`date` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + type (:obj:`str`): Type of the message origin, can be on of: + :attr:`~telegram.MessageOrigin.USER`, :attr:`~telegram.MessageOrigin.HIDDEN_USER`, + :attr:`~telegram.MessageOrigin.CHAT`, or :attr:`~telegram.MessageOrigin.CHANNEL`. + date (:obj:`datetime.datetime`): Date the message was sent originally. + |datetime_localization| + + Attributes: + type (:obj:`str`): Type of the message origin, can be on of: + :attr:`~telegram.MessageOrigin.USER`, :attr:`~telegram.MessageOrigin.HIDDEN_USER`, + :attr:`~telegram.MessageOrigin.CHAT`, or :attr:`~telegram.MessageOrigin.CHANNEL`. + date (:obj:`datetime.datetime`): Date the message was sent originally. + |datetime_localization| + """ + + __slots__ = ( + "date", + "type", + ) + + USER: Final[str] = constants.MessageOriginType.USER + """:const:`telegram.constants.MessageOriginType.USER`""" + HIDDEN_USER: Final[str] = constants.MessageOriginType.HIDDEN_USER + """:const:`telegram.constants.MessageOriginType.HIDDEN_USER`""" + CHAT: Final[str] = constants.MessageOriginType.CHAT + """:const:`telegram.constants.MessageOriginType.CHAT`""" + CHANNEL: Final[str] = constants.MessageOriginType.CHANNEL + """:const:`telegram.constants.MessageOriginType.CHANNEL`""" + + def __init__( + self, + type: str, # pylint: disable=W0622 + date: datetime.datetime, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required by all subclasses + self.type: str = enum.get_member(constants.MessageOriginType, type, type) + self.date: datetime.datetime = date + + self._id_attrs = ( + self.type, + self.date, + ) + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["MessageOrigin"]: + """Converts JSON data to the appropriate :class:`MessageOrigin` object, i.e. takes + care of selecting the correct subclass. + """ + data = cls._parse_data(data) + + if not data: + return None + + _class_mapping: Dict[str, Type[MessageOrigin]] = { + cls.USER: MessageOriginUser, + cls.HIDDEN_USER: MessageOriginHiddenUser, + cls.CHAT: MessageOriginChat, + cls.CHANNEL: MessageOriginChannel, + } + if cls is MessageOrigin and data.get("type") in _class_mapping: + return _class_mapping[data.pop("type")].de_json(data=data, bot=bot) + + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["date"] = from_timestamp(data.get("date"), tzinfo=loc_tzinfo) + + if "sender_user" in data: + data["sender_user"] = User.de_json(data.get("sender_user"), bot) + + if "sender_chat" in data: + data["sender_chat"] = Chat.de_json(data.get("sender_chat"), bot) + + if "chat" in data: + data["chat"] = Chat.de_json(data.get("chat"), bot) + + return super().de_json(data=data, bot=bot) + + +class MessageOriginUser(MessageOrigin): + """ + The message was originally sent by a known user. + + .. versionadded:: NEXT.VERSION + + Args: + date (:obj:`datetime.datetime`): Date the message was sent originally. + |datetime_localization| + sender_user (:class:`telegram.User`): User that sent the message originally. + + Attributes: + type (:obj:`str`): Type of the message origin. Always + :tg-const:`~telegram.MessageOrigin.USER`. + date (:obj:`datetime.datetime`): Date the message was sent originally. + |datetime_localization| + sender_user (:class:`telegram.User`): User that sent the message originally. + """ + + __slots__ = ("sender_user",) + + def __init__( + self, + date: datetime.datetime, + sender_user: User, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(type=self.USER, date=date, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.sender_user: User = sender_user + + +class MessageOriginHiddenUser(MessageOrigin): + """ + The message was originally sent by an unknown user. + + .. versionadded:: NEXT.VERSION + + Args: + date (:obj:`datetime.datetime`): Date the message was sent originally. + |datetime_localization| + sender_user_name (:obj:`str`): Name of the user that sent the message originally. + + Attributes: + type (:obj:`str`): Type of the message origin. Always + :tg-const:`~telegram.MessageOrigin.HIDDEN_USER`. + date (:obj:`datetime.datetime`): Date the message was sent originally. + |datetime_localization| + sender_user_name (:obj:`str`): Name of the user that sent the message originally. + """ + + __slots__ = ("sender_user_name",) + + def __init__( + self, + date: datetime.datetime, + sender_user_name: str, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(type=self.HIDDEN_USER, date=date, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.sender_user_name: str = sender_user_name + + +class MessageOriginChat(MessageOrigin): + """ + The message was originally sent on behalf of a chat to a group chat. + + .. versionadded:: NEXT.VERSION + + Args: + date (:obj:`datetime.datetime`): Date the message was sent originally. + |datetime_localization| + sender_chat (:class:`telegram.Chat`): Chat that sent the message originally. + author_signature (:obj:`str`, optional): For messages originally sent by an anonymous chat + administrator, original message author signature + + Attributes: + type (:obj:`str`): Type of the message origin. Always + :tg-const:`~telegram.MessageOrigin.CHAT`. + date (:obj:`datetime.datetime`): Date the message was sent originally. + |datetime_localization| + sender_chat (:class:`telegram.Chat`): Chat that sent the message originally. + author_signature (:obj:`str`): Optional. For messages originally sent by an anonymous chat + administrator, original message author signature + """ + + __slots__ = ( + "author_signature", + "sender_chat", + ) + + def __init__( + self, + date: datetime.datetime, + sender_chat: Chat, + author_signature: Optional[str] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(type=self.CHAT, date=date, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.sender_chat: Chat = sender_chat + self.author_signature: Optional[str] = author_signature + + +class MessageOriginChannel(MessageOrigin): + """ + The message was originally sent to a channel chat. + + .. versionadded:: NEXT.VERSION + + Args: + date (:obj:`datetime.datetime`): Date the message was sent originally. + |datetime_localization| + chat (:class:`telegram.Chat`): Channel chat to which the message was originally sent. + message_id (:obj:`int`): Unique message identifier inside the chat. + author_signature (:obj:`str`, optional): Signature of the original post author. + + Attributes: + type (:obj:`str`): Type of the message origin. Always + :tg-const:`~telegram.MessageOrigin.CHANNEL`. + date (:obj:`datetime.datetime`): Date the message was sent originally. + |datetime_localization| + chat (:class:`telegram.Chat`): Channel chat to which the message was originally sent. + message_id (:obj:`int`): Unique message identifier inside the chat. + author_signature (:obj:`str`): Optional. Signature of the original post author. + """ + + __slots__ = ( + "author_signature", + "chat", + "message_id", + ) + + def __init__( + self, + date: datetime.datetime, + chat: Chat, + message_id: int, + author_signature: Optional[str] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(type=self.CHANNEL, date=date, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.chat: Chat = chat + self.message_id: int = message_id + self.author_signature: Optional[str] = author_signature diff --git a/telegram/_messagereactionupdated.py b/telegram/_messagereactionupdated.py new file mode 100644 index 00000000000..002d1a9bf9f --- /dev/null +++ b/telegram/_messagereactionupdated.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# 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 MessageReaction Update.""" +from datetime import datetime +from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +from telegram._chat import Chat +from telegram._reaction import ReactionCount, ReactionType +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class MessageReactionCountUpdated(TelegramObject): + """This class represents reaction changes on a message with anonymous reactions. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if the :attr:`chat`, :attr:`message_id`, :attr:`date` and :attr:`reactions` + is equal. + + .. versionadded:: NEXT.VERSION + + Args: + chat (:class:`telegram.Chat`): The chat containing the message. + message_id (:obj:`int`): Unique message identifier inside the chat. + date (:class:`datetime.datetime`): Date of the change in Unix time + |datetime_localization| + reactions (Sequence[:class:`telegram.ReactionCount`]): List of reactions that are present + on the message + + Attributes: + chat (:class:`telegram.Chat`): The chat containing the message. + message_id (:obj:`int`): Unique message identifier inside the chat. + date (:class:`datetime.datetime`): Date of the change in Unix time + |datetime_localization| + reactions (Tuple[:class:`telegram.ReactionCount`]): List of reactions that are present on + the message + """ + + __slots__ = ( + "chat", + "date", + "message_id", + "reactions", + ) + + def __init__( + self, + chat: Chat, + message_id: int, + date: datetime, + reactions: Sequence[ReactionCount], + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.chat: Chat = chat + self.message_id: int = message_id + self.date: datetime = date + self.reactions: Tuple[ReactionCount, ...] = parse_sequence_arg(reactions) + + self._id_attrs = (self.chat, self.message_id, self.date, self.reactions) + self._freeze() + + @classmethod + def de_json( + cls, data: Optional[JSONDict], bot: "Bot" + ) -> Optional["MessageReactionCountUpdated"]: + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if not data: + return None + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + data["date"] = from_timestamp(data.get("date"), tzinfo=loc_tzinfo) + data["chat"] = Chat.de_json(data.get("chat"), bot) + data["reactions"] = ReactionCount.de_list(data.get("reactions"), bot) + + return super().de_json(data=data, bot=bot) + + +class MessageReactionUpdated(TelegramObject): + """This class represents a change of a reaction on a message performed by a user. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if the :attr:`chat`, :attr:`message_id`, :attr:`date`, :attr:`old_reaction` + and :attr:`new_reaction` is equal. + + .. versionadded:: NEXT.VERSION + + Args: + chat (:class:`telegram.Chat`): The chat containing the message. + message_id (:obj:`int`): Unique message identifier inside the chat. + date (:class:`datetime.datetime`): Date of the change in Unix time. + |datetime_localization| + old_reaction (Sequence[:class:`telegram.ReactionType`]): Previous list of reaction types + that were set by the user. + new_reaction (Sequence[:class:`telegram.ReactionType`]): New list of reaction types that + were set by the user. + user (:class:`telegram.User`, optional): The user that changed the reaction, if the user + isn't anonymous. + actor_chat (:class:`telegram.Chat`, optional): The chat on behalf of which the reaction was + changed, if the user is anonymous. + + Attributes: + chat (:class:`telegram.Chat`): The chat containing the message. + message_id (:obj:`int`): Unique message identifier inside the chat. + date (:class:`datetime.datetime`): Date of the change in Unix time. + |datetime_localization| + old_reaction (Tuple[:class:`telegram.ReactionType`]): Previous list of reaction types + that were set by the user. + new_reaction (Tuple[:class:`telegram.ReactionType`]): New list of reaction types that + were set by the user. + user (:class:`telegram.User`): Optional. The user that changed the reaction, if the user + isn't anonymous. + actor_chat (:class:`telegram.Chat`): Optional. The chat on behalf of which the reaction was + changed, if the user is anonymous. + """ + + __slots__ = ( + "actor_chat", + "chat", + "date", + "message_id", + "new_reaction", + "old_reaction", + "user", + ) + + def __init__( + self, + chat: Chat, + message_id: int, + date: datetime, + old_reaction: Sequence[ReactionType], + new_reaction: Sequence[ReactionType], + user: Optional[User] = None, + actor_chat: Optional[Chat] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.chat: Chat = chat + self.message_id: int = message_id + self.date: datetime = date + self.old_reaction: Tuple[ReactionType, ...] = parse_sequence_arg(old_reaction) + self.new_reaction: Tuple[ReactionType, ...] = parse_sequence_arg(new_reaction) + + # Optional + self.user: Optional[User] = user + self.actor_chat: Optional[Chat] = actor_chat + + self._id_attrs = ( + self.chat, + self.message_id, + self.date, + self.old_reaction, + self.new_reaction, + ) + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["MessageReactionUpdated"]: + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if not data: + return None + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + data["date"] = from_timestamp(data.get("date"), tzinfo=loc_tzinfo) + data["chat"] = Chat.de_json(data.get("chat"), bot) + data["old_reaction"] = ReactionType.de_list(data.get("old_reaction"), bot) + data["new_reaction"] = ReactionType.de_list(data.get("new_reaction"), bot) + data["user"] = User.de_json(data.get("user"), bot) + data["actor_chat"] = Chat.de_json(data.get("actor_chat"), bot) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_reaction.py b/telegram/_reaction.py new file mode 100644 index 00000000000..5860409d51b --- /dev/null +++ b/telegram/_reaction.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2023 +# 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 objects that represents a Telegram ReactionType.""" +from typing import TYPE_CHECKING, Final, Literal, Optional, Union + +from telegram import constants +from telegram._telegramobject import TelegramObject +from telegram._utils import enum +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class ReactionType(TelegramObject): + """Base class for Telegram ReactionType Objects. + There exist :class:`telegram.ReactionTypeEmoji` and :class:`telegram.ReactionTypeCustomEmoji`. + + .. versionadded:: NEXT.VERSION + + Args: + type (:obj:`str`): Type of the reaction. Can be + :attr:`~telegram.ReactionType.EMOJI` or :attr:`~telegram.ReactionType.CUSTOM_EMOJI`. + Attributes: + type (:obj:`str`): Type of the reaction. Can be + :attr:`~telegram.ReactionType.EMOJI` or :attr:`~telegram.ReactionType.CUSTOM_EMOJI`. + + """ + + __slots__ = ("type",) + + EMOJI: Final[constants.ReactionType] = constants.ReactionType.EMOJI + """:const:`telegram.constants.ReactionType.EMOJI`""" + CUSTOM_EMOJI: Final[constants.ReactionType] = constants.ReactionType.CUSTOM_EMOJI + """:const:`telegram.constants.ReactionType.CUSTOM_EMOJI`""" + + def __init__( + self, + type: Union[ # pylint: disable=redefined-builtin + Literal["emoji", "custom_emoji"], constants.ReactionType + ], + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required by all subclasses + self.type: str = enum.get_member(constants.ReactionType, type, type) + + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ReactionType"]: + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if not data: + return None + + if cls is ReactionType and data.get("type") in [cls.EMOJI, cls.CUSTOM_EMOJI]: + reaction_type = data.pop("type") + if reaction_type == cls.EMOJI: + return ReactionTypeEmoji.de_json(data=data, bot=bot) + return ReactionTypeCustomEmoji.de_json(data=data, bot=bot) + + return super().de_json(data=data, bot=bot) + + +class ReactionTypeEmoji(ReactionType): + """ + Represents a reaction with a normal emoji. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if the :attr:`emoji` is equal. + + .. versionadded:: NEXT.VERSION + + Args: + emoji (:obj:`str`): Reaction emoji. It can be one of + :const:`telegram.constants.ReactionEmoji`. + + Attributes: + type (:obj:`str`): Type of the reaction, + always :tg-const:`telegram.ReactionType.EMOJI`. + emoji (:obj:`str`): Reaction emoji. It can be one of + :const:`telegram.constants.ReactionEmoji`. + """ + + __slots__ = ("emoji",) + + def __init__( + self, + emoji: str, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(type=ReactionType.EMOJI, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.emoji: str = emoji + self._id_attrs = (self.emoji,) + + +class ReactionTypeCustomEmoji(ReactionType): + """ + Represents a reaction with a custom emoji. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if the :attr:`custom_emoji_id` is equal. + + .. versionadded:: NEXT.VERSION + + Args: + custom_emoji_id (:obj:`str`): Custom emoji identifier. + + Attributes: + type (:obj:`str`): Type of the reaction, + always :tg-const:`telegram.ReactionType.CUSTOM_EMOJI`. + custom_emoji_id (:obj:`str`): Custom emoji identifier. + + """ + + __slots__ = ("custom_emoji_id",) + + def __init__( + self, + custom_emoji_id: str, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(type=ReactionType.CUSTOM_EMOJI, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.custom_emoji_id: str = custom_emoji_id + self._id_attrs = (self.custom_emoji_id,) + + +class ReactionCount(TelegramObject): + """This class represents a reaction added to a message along with the number of times it was + added. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if the :attr:`type` and :attr:`total_count` is equal. + + .. versionadded:: NEXT.VERSION + + Args: + type (:class:`telegram.ReactionType`): Type of the reaction. + total_count (:obj:`int`): Number of times the reaction was added. + + Attributes: + type (:class:`telegram.ReactionType`): Type of the reaction. + total_count (:obj:`int`): Number of times the reaction was added. + """ + + __slots__ = ( + "total_count", + "type", + ) + + def __init__( + self, + type: ReactionType, # pylint: disable=redefined-builtin + total_count: int, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.type: ReactionType = type + self.total_count: int = total_count + + self._id_attrs = ( + self.type, + self.total_count, + ) + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ReactionCount"]: + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if not data: + return None + + data["type"] = ReactionType.de_json(data.get("type"), bot) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_reply.py b/telegram/_reply.py new file mode 100644 index 00000000000..55f8d0f8d39 --- /dev/null +++ b/telegram/_reply.py @@ -0,0 +1,444 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2023 +# 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 modules contains objects that represents Telegram Replies""" +from typing import TYPE_CHECKING, Optional, Sequence, Tuple, Union + +from telegram._chat import Chat +from telegram._dice import Dice +from telegram._files.animation import Animation +from telegram._files.audio import Audio +from telegram._files.contact import Contact +from telegram._files.document import Document +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._files.videonote import VideoNote +from telegram._files.voice import Voice +from telegram._games.game import Game +from telegram._giveaway import Giveaway, GiveawayWinners +from telegram._linkpreviewoptions import LinkPreviewOptions +from telegram._messageentity import MessageEntity +from telegram._messageorigin import MessageOrigin +from telegram._payment.invoice import Invoice +from telegram._poll import Poll +from telegram._story import Story +from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram._utils.types import JSONDict, ODVInput + +if TYPE_CHECKING: + from telegram import Bot + + +class ExternalReplyInfo(TelegramObject): + """ + This object contains information about a message that is being replied to, which may + come from another chat or forum topic. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`origin` is equal. + + .. versionadded:: NEXT.VERSION + + Args: + origin (:class:`telegram.MessageOrigin`): Origin of the message replied to by the given + message. + chat (:class:`telegram.Chat`, optional): Chat the original message belongs to. Available + only if the chat is a supergroup or a channel. + message_id (:obj:`int`, optional): Unique message identifier inside the original chat. + Available only if the original chat is a supergroup or a channel. + link_preview_options (:class:`telegram.LinkPreviewOptions`, optional): Options used for + link preview generation for the original message, if it is a text message + animation (:class:`telegram.Animation`, optional): Message is an animation, information + about the animation. + audio (:class:`telegram.Audio`, optional): Message is an audio file, information about the + file. + document (:class:`telegram.Document`, optional): Message is a general file, information + about the file. + photo (Sequence[:class:`telegram.PhotoSize`], optional): Message is a photo, available + sizes of the photo. + sticker (:class:`telegram.Sticker`, optional): Message is a sticker, information about the + sticker. + story (:class:`telegram.Story`, optional): Message is a forwarded story. + video (:class:`telegram.Video`, optional): Message is a video, information about the video. + video_note (:class:`telegram.VideoNote`, optional): Message is a video note, information + about the video message. + voice (:class:`telegram.Voice`, optional): Message is a voice message, information about + the file. + has_media_spoiler (:obj:`bool`, optional): :obj:`True`, if the message media is covered by + a spoiler animation. + contact (:class:`telegram.Contact`, optional): Message is a shared contact, information + about the contact. + dice (:class:`telegram.Dice`, optional): Message is a dice with random value. + game (:Class:`telegram.Game`. optional): Message is a game, information about the game. + giveaway (:class:`telegram.Giveaway`, optional): Message is a scheduled giveaway, + information about the giveaway. + giveaway_winners (:class:`telegram.GiveawayWinners`, optional): A giveaway with public + winners was completed. + invoice (:class:`telegram.Invoice`, optional): Message is an invoice for a payment, + information about the invoice. + location (:class:`telegram.Location`, optional): Message is a shared location, information + about the location. + poll (:class:`telegram.Poll`, optional): Message is a native poll, information about the + poll. + venue (:class:`telegram.Venue`, optional): Message is a venue, information about the venue. + + Attributes: + origin (:class:`telegram.MessageOrigin`): Origin of the message replied to by the given + message. + chat (:class:`telegram.Chat`): Optional. Chat the original message belongs to. Available + only if the chat is a supergroup or a channel. + message_id (:obj:`int`): Optional. Unique message identifier inside the original chat. + Available only if the original chat is a supergroup or a channel. + link_preview_options (:class:`telegram.LinkPreviewOptions`): Optional. Options used for + link preview generation for the original message, if it is a text message. + animation (:class:`telegram.Animation`): Optional. Message is an animation, information + about the animation. + audio (:class:`telegram.Audio`): Optional. Message is an audio file, information about the + file. + document (:class:`telegram.Document`): Optional. Message is a general file, information + about the file. + photo (Tuple[:class:`telegram.PhotoSize`]): Optional. Message is a photo, available sizes + of the photo. + sticker (:class:`telegram.Sticker`): Optional. Message is a sticker, information about the + sticker. + story (:class:`telegram.Story`): Optional. Message is a forwarded story. + video (:class:`telegram.Video`): Optional. Message is a video, information about the video. + video_note (:class:`telegram.VideoNote`): Optional. Message is a video note, information + about the video message. + voice (:class:`telegram.Voice`): Optional. Message is a voice message, information about + the file. + has_media_spoiler (:obj:`bool`): Optional. :obj:`True`, if the message media is covered by + a spoiler animation. + contact (:class:`telegram.Contact`): Optional. Message is a shared contact, information + about the contact. + dice (:class:`telegram.Dice`): Optional. Message is a dice with random value. + game (:Class:`telegram.Game`): Optional. Message is a game, information about the game. + giveaway (:class:`telegram.Giveaway`): Optional. Message is a scheduled giveaway, + information about the giveaway. + giveaway_winners (:class:`telegram.GiveawayWinners`): Optional. A giveaway with public + winners was completed. + invoice (:class:`telegram.Invoice`): Optional. Message is an invoice for a payment, + information about the invoice. + location (:class:`telegram.Location`): Optional. Message is a shared location, information + about the location. + poll (:class:`telegram.Poll`): Optional. Message is a native poll, information about the + poll. + venue (:class:`telegram.Venue`): Optional. Message is a venue, information about the venue. + """ + + __slots__ = ( + "animation", + "audio", + "chat", + "contact", + "dice", + "document", + "game", + "giveaway", + "giveaway_winners", + "has_media_spoiler", + "invoice", + "link_preview_options", + "location", + "message_id", + "origin", + "photo", + "poll", + "sticker", + "story", + "venue", + "video", + "video_note", + "voice", + ) + + def __init__( + self, + origin: MessageOrigin, + chat: Optional[Chat] = None, + message_id: Optional[int] = None, + link_preview_options: Optional[LinkPreviewOptions] = None, + animation: Optional[Animation] = None, + audio: Optional[Audio] = None, + document: Optional[Document] = None, + photo: Optional[Sequence[PhotoSize]] = None, + sticker: Optional[Sticker] = None, + story: Optional[Story] = None, + video: Optional[Video] = None, + video_note: Optional[VideoNote] = None, + voice: Optional[Voice] = None, + has_media_spoiler: Optional[bool] = None, + contact: Optional[Contact] = None, + dice: Optional[Dice] = None, + game: Optional[Game] = None, + giveaway: Optional[Giveaway] = None, + giveaway_winners: Optional[GiveawayWinners] = None, + invoice: Optional[Invoice] = None, + location: Optional[Location] = None, + poll: Optional[Poll] = None, + venue: Optional[Venue] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + + self.origin: MessageOrigin = origin + self.chat: Optional[Chat] = chat + self.message_id: Optional[int] = message_id + self.link_preview_options: Optional[LinkPreviewOptions] = link_preview_options + self.animation: Optional[Animation] = animation + self.audio: Optional[Audio] = audio + self.document: Optional[Document] = document + self.photo: Optional[Tuple[PhotoSize, ...]] = parse_sequence_arg(photo) + self.sticker: Optional[Sticker] = sticker + self.story: Optional[Story] = story + self.video: Optional[Video] = video + self.video_note: Optional[VideoNote] = video_note + self.voice: Optional[Voice] = voice + self.has_media_spoiler: Optional[bool] = has_media_spoiler + self.contact: Optional[Contact] = contact + self.dice: Optional[Dice] = dice + self.game: Optional[Game] = game + self.giveaway: Optional[Giveaway] = giveaway + self.giveaway_winners: Optional[GiveawayWinners] = giveaway_winners + self.invoice: Optional[Invoice] = invoice + self.location: Optional[Location] = location + self.poll: Optional[Poll] = poll + self.venue: Optional[Venue] = venue + + self._id_attrs = (self.origin,) + + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ExternalReplyInfo"]: + """See :obj:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if data is None: + return None + + data["origin"] = MessageOrigin.de_json(data.get("origin"), bot) + data["chat"] = Chat.de_json(data.get("chat"), bot) + data["link_preview_options"] = LinkPreviewOptions.de_json( + data.get("link_preview_options"), bot + ) + data["animation"] = Animation.de_json(data.get("animation"), bot) + data["audio"] = Audio.de_json(data.get("audio"), bot) + data["document"] = Document.de_json(data.get("document"), bot) + data["photo"] = tuple(PhotoSize.de_list(data.get("photo"), bot)) + data["sticker"] = Sticker.de_json(data.get("sticker"), bot) + data["story"] = Story.de_json(data.get("story"), bot) + data["video"] = Video.de_json(data.get("video"), bot) + data["video_note"] = VideoNote.de_json(data.get("video_note"), bot) + data["voice"] = Voice.de_json(data.get("voice"), bot) + data["contact"] = Contact.de_json(data.get("contact"), bot) + data["dice"] = Dice.de_json(data.get("dice"), bot) + data["game"] = Game.de_json(data.get("game"), bot) + data["giveaway"] = Giveaway.de_json(data.get("giveaway"), bot) + data["giveaway_winners"] = GiveawayWinners.de_json(data.get("giveaway_winners"), bot) + data["invoice"] = Invoice.de_json(data.get("invoice"), bot) + data["location"] = Location.de_json(data.get("location"), bot) + data["poll"] = Poll.de_json(data.get("poll"), bot) + data["venue"] = Venue.de_json(data.get("venue"), bot) + + return super().de_json(data=data, bot=bot) + + +class TextQuote(TelegramObject): + """ + This object contains information about the quoted part of a message that is replied to + by the given message. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`text` and :attr:`position` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + text (:obj:`str`): Text of the quoted part of a message that is replied to by the given + message. + position (:obj:`int`): Approximate quote position in the original message in UTF-16 code + units as specified by the sender. + entities (Sequence[:obj:`telegram.MessageEntity`], optional): Special entities that appear + in the quote. Currently, only bold, italic, underline, strikethrough, spoiler, and + custom_emoji entities are kept in quotes. + is_manual (:obj:`bool`, optional): :obj:`True`, if the quote was chosen manually by the + message sender. Otherwise, the quote was added automatically by the server. + + Attributes: + text (:obj:`str`): Text of the quoted part of a message that is replied to by the given + message. + position (:obj:`int`): Approximate quote position in the original message in UTF-16 code + units as specified by the sender. + entities (Tuple[:obj:`telegram.MessageEntity`]): Optional. Special entities that appear + in the quote. Currently, only bold, italic, underline, strikethrough, spoiler, and + custom_emoji entities are kept in quotes. + is_manual (:obj:`bool`): Optional. :obj:`True`, if the quote was chosen manually by the + message sender. Otherwise, the quote was added automatically by the server. + """ + + __slots__ = ( + "entities", + "is_manual", + "position", + "text", + ) + + def __init__( + self, + text: str, + position: int, + entities: Optional[Sequence[MessageEntity]] = None, + is_manual: Optional[bool] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + + self.text: str = text + self.position: int = position + self.entities: Optional[Tuple[MessageEntity, ...]] = parse_sequence_arg(entities) + self.is_manual: Optional[bool] = is_manual + + self._id_attrs = ( + self.text, + self.position, + ) + + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["TextQuote"]: + """See :obj:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if data is None: + return None + + data["entities"] = tuple(MessageEntity.de_list(data.get("entities"), bot)) + + return super().de_json(data=data, bot=bot) + + +class ReplyParameters(TelegramObject): + """ + Describes reply parameters for the message that is being sent. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`message_id` is equal. + + .. versionadded:: NEXT.VERSION + + Args: + message_id (:obj:`int`): Identifier of the message that will be replied to in the current + chat, or in the chat :paramref:`chat_id` if it is specified. + chat_id (:obj:`int` | :obj:`str`, optional): If the message to be replied to is from a + different chat, |chat_id_channel| + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| Can be + used only for replies in the same chat and forum topic. + quote (:obj:`str`, optional): Quoted part of the message to be replied to; 0-1024 + characters after entities parsing. The quote must be an exact substring of the message + to be replied to, including bold, italic, underline, strikethrough, spoiler, and + custom_emoji entities. The message will fail to send if the quote isn't found in the + original message. + quote_parse_mode (:obj:`str`, optional): Mode for parsing entities in the quote. See + :wiki:`formatting options ` for + more details. + quote_entities (Sequence[:obj:`telegram.MessageEntity`], optional): A JSON-serialized list + of special entities that appear in the quote. It can be specified instead of + :paramref:`quote_parse_mode`. + quote_position (:obj:`int`, optional): Position of the quote in the original message in + UTF-16 code units. + + Attributes: + message_id (:obj:`int`): Identifier of the message that will be replied to in the current + chat, or in the chat :paramref:`chat_id` if it is specified. + chat_id (:obj:`int` | :obj:`str`): Optional. If the message to be replied to is from a + different chat, |chat_id_channel| + allow_sending_without_reply (:obj:`bool`): Optional. |allow_sending_without_reply| Can be + used only for replies in the same chat and forum topic. + quote (:obj:`str`): Optional. Quoted part of the message to be replied to; 0-1024 + characters after entities parsing. The quote must be an exact substring of the message + to be replied to, including bold, italic, underline, strikethrough, spoiler, and + custom_emoji entities. The message will fail to send if the quote isn't found in the + original message. + quote_parse_mode (:obj:`str`): Optional. Mode for parsing entities in the quote. See + :wiki:`formatting options ` for + more details. + quote_entities (Tuple[:obj:`telegram.MessageEntity`]): Optional. A JSON-serialized list of + special entities that appear in the quote. It can be specified instead of + :paramref:`quote_parse_mode`. + quote_position (:obj:`int`): Optional. Position of the quote in the original message in + UTF-16 code units. + """ + + __slots__ = ( + "allow_sending_without_reply", + "chat_id", + "message_id", + "quote", + "quote_entities", + "quote_parse_mode", + "quote_position", + ) + + def __init__( + self, + message_id: int, + chat_id: Optional[Union[int, str]] = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + quote: Optional[str] = None, + quote_parse_mode: ODVInput[str] = DEFAULT_NONE, + quote_entities: Optional[Sequence[MessageEntity]] = None, + quote_position: Optional[int] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + + self.message_id: int = message_id + self.chat_id: Optional[Union[int, str]] = chat_id + self.allow_sending_without_reply: ODVInput[bool] = allow_sending_without_reply + self.quote: Optional[str] = quote + self.quote_parse_mode: ODVInput[str] = quote_parse_mode + self.quote_entities: Optional[Tuple[MessageEntity, ...]] = parse_sequence_arg( + quote_entities + ) + self.quote_position: Optional[int] = quote_position + + self._id_attrs = (self.message_id,) + + self._freeze() + + @classmethod + def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ReplyParameters"]: + """See :obj:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if data is None: + return None + + data["quote_entities"] = tuple(MessageEntity.de_list(data.get("quote_entities"), bot)) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_replykeyboardmarkup.py b/telegram/_replykeyboardmarkup.py index 0cde187b5ce..a9b379d5f66 100644 --- a/telegram/_replykeyboardmarkup.py +++ b/telegram/_replykeyboardmarkup.py @@ -65,8 +65,8 @@ class ReplyKeyboardMarkup(TelegramObject): 1) Users that are @mentioned in the :attr:`~telegram.Message.text` of the :class:`telegram.Message` object. - 2) If the bot's message is a reply (has ``reply_to_message_id``), sender of the - original message. + 2) If the bot's message is a reply to a message in the same chat and forum topic, + sender of the original message. Defaults to :obj:`False`. @@ -99,8 +99,8 @@ class ReplyKeyboardMarkup(TelegramObject): 1) Users that are @mentioned in the :attr:`~telegram.Message.text` of the :class:`telegram.Message` object. - 2) If the bot's message is a reply (has ``reply_to_message_id``), sender of the - original message. + 2) If the bot's message is a reply to a message in the same chat and forum topic, + sender of the original message. Defaults to :obj:`False`. @@ -196,8 +196,8 @@ def from_button( to specific users only. Targets: 1) Users that are @mentioned in the text of the Message object. - 2) If the bot's message is a reply (has ``reply_to_message_id``), sender of the - original message. + 2) If the bot's message is a reply to a message in the same chat and forum topic, + sender of the original message. Defaults to :obj:`False`. @@ -257,8 +257,8 @@ def from_row( to specific users only. Targets: 1) Users that are @mentioned in the text of the Message object. - 2) If the bot's message is a reply (has ``reply_to_message_id``), sender of the - original message. + 2) If the bot's message is a reply to a message in the same chat and forum topic, + sender of the original message. Defaults to :obj:`False`. @@ -319,8 +319,8 @@ def from_column( to specific users only. Targets: 1) Users that are @mentioned in the text of the Message object. - 2) If the bot's message is a reply (has ``reply_to_message_id``), sender of the - original message. + 2) If the bot's message is a reply to a message in the same chat and forum topic, + sender of the original message. Defaults to :obj:`False`. diff --git a/telegram/_replykeyboardremove.py b/telegram/_replykeyboardremove.py index ace6ecc4935..cae4b97799c 100644 --- a/telegram/_replykeyboardremove.py +++ b/telegram/_replykeyboardremove.py @@ -46,8 +46,8 @@ class ReplyKeyboardRemove(TelegramObject): for specific users only. Targets: 1) Users that are @mentioned in the text of the :class:`telegram.Message` object. - 2) If the bot's message is a reply (has ``reply_to_message_id``), sender of - the original message. + 2) If the bot's message is a reply to a message in the same chat and forum topic, + sender of the original message. Attributes: remove_keyboard (:obj:`True`): Requests clients to remove the custom keyboard. @@ -55,8 +55,8 @@ class ReplyKeyboardRemove(TelegramObject): Targets: 1) Users that are @mentioned in the text of the :class:`telegram.Message` object. - 2) If the bot's message is a reply (has ``reply_to_message_id``), sender of - the original message. + 2) If the bot's message is a reply to a message in the same chat and forum topic, + sender of the original message. """ diff --git a/telegram/_shared.py b/telegram/_shared.py index e2fa35540c1..9559858d8e9 100644 --- a/telegram/_shared.py +++ b/telegram/_shared.py @@ -17,55 +17,116 @@ # 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 two objects used for request chats/users service messages.""" -from typing import Optional +from typing import Optional, Sequence, Tuple from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict +from telegram._utils.warnings import warn +from telegram._utils.warnings_transition import ( + build_deprecation_warning_message, + warn_about_deprecated_attr_in_property, +) +from telegram.warnings import PTBDeprecationWarning -class UserShared(TelegramObject): +class UsersShared(TelegramObject): """ This object contains information about the user whose identifier was shared with the bot - using a :class:`telegram.KeyboardButtonRequestUser` button. + using a :class:`telegram.KeyboardButtonRequestUsers` button. Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal, if their :attr:`request_id` and :attr:`user_id` are equal. + considered equal, if their :attr:`request_id` and :attr:`user_ids` are equal. - .. versionadded:: 20.1 + .. versionadded:: NEXT.VERSION + Bot API 7.0 replaces :class:`UserShared` with this class. The only difference is that now + the :attr:`user_ids` is a sequence instead of a single integer. Args: request_id (:obj:`int`): Identifier of the request. - user_id (:obj:`int`): Identifier of the shared user. This number may be greater than 32 - bits and some programming languages may have difficulty/silent defects in interpreting - it. But it is smaller than 52 bits, so a signed 64-bit integer or double-precision - float type are safe for storing this identifier. + user_ids (Sequence[:obj:`int`]): Identifiers of the shared users. These numbers may have + more than 32 significant bits and some programming languages may have difficulty/silent + defects in interpreting them. But they have at most 52 significant bits, so 64-bit + integers or double-precision float types are safe for storing these identifiers. The + bot may not have access to the users and could be unable to use these identifiers, + unless the users are already known to the bot by some other means. Attributes: request_id (:obj:`int`): Identifier of the request. - user_id (:obj:`int`): Identifier of the shared user. This number may be greater than 32 - bits and some programming languages may have difficulty/silent defects in interpreting - it. But it is smaller than 52 bits, so a signed 64-bit integer or double-precision - float type are safe for storing this identifier. + user_ids (Tuple[:obj:`int`]): Identifiers of the shared users. These numbers may have + more than 32 significant bits and some programming languages may have difficulty/silent + defects in interpreting them. But they have at most 52 significant bits, so 64-bit + integers or double-precision float types are safe for storing these identifiers. The + bot may not have access to the users and could be unable to use these identifiers, + unless the users are already known to the bot by some other means. """ - __slots__ = ("request_id", "user_id") + __slots__ = ("request_id", "user_ids") def __init__( self, request_id: int, - user_id: int, + user_ids: Sequence[int], *, api_kwargs: Optional[JSONDict] = None, ): super().__init__(api_kwargs=api_kwargs) self.request_id: int = request_id - self.user_id: int = user_id + self.user_ids: Tuple[int, ...] = tuple(user_ids) - self._id_attrs = (self.request_id, self.user_id) + self._id_attrs = (self.request_id, self.user_ids) self._freeze() +class UserShared(UsersShared): + """Alias for :class:`UsersShared`, kept for backward compatibility. + + .. versionadded:: 20.1 + + .. deprecated:: NEXT.VERSION + Use :class:`UsersShared` instead. + + """ + + __slots__ = () + + def __init__( + self, + request_id: int, + user_id: int, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(request_id, (user_id,), api_kwargs=api_kwargs) + + warn( + build_deprecation_warning_message( + deprecated_name="UserShared", + new_name="UsersShared", + object_type="class", + bot_api_version="7.0", + ), + PTBDeprecationWarning, + stacklevel=2, + ) + + self._freeze() + + @property + def user_id(self) -> int: + """Alias for the first entry of :attr:`UsersShared.user_ids`. + + .. deprecated:: NEXT.VERSION + Bot API 7.0 deprecates this attribute in favor of :attr:`UsersShared.user_ids`. + """ + warn_about_deprecated_attr_in_property( + deprecated_attr_name="user_id", + new_attr_name="user_ids", + bot_api_version="7.0", + ) + return self.user_ids[0] + + class ChatShared(TelegramObject): """ This object contains information about the chat whose identifier was shared with the bot diff --git a/telegram/_telegramobject.py b/telegram/_telegramobject.py index aad24cda13c..0f26ada7600 100644 --- a/telegram/_telegramobject.py +++ b/telegram/_telegramobject.py @@ -43,6 +43,7 @@ ) from telegram._utils.datetime import to_timestamp +from telegram._utils.defaultvalue import DefaultValue from telegram._utils.types import JSONDict from telegram._utils.warnings import warn @@ -271,7 +272,9 @@ def __getstate__(self) -> Dict[str, Union[str, object]]: Returns: state (Dict[:obj:`str`, :obj:`object`]): The state of the object. """ - out = self._get_attrs(include_private=True, recursive=False, remove_bot=True) + out = self._get_attrs( + include_private=True, recursive=False, remove_bot=True, convert_default_vault=False + ) # MappingProxyType is not pickable, so we convert it to a dict and revert in # __setstate__ out["api_kwargs"] = dict(self.api_kwargs) @@ -519,6 +522,7 @@ def _get_attrs( include_private: bool = False, recursive: bool = False, remove_bot: bool = False, + convert_default_vault: bool = True, ) -> Dict[str, Union[str, object]]: """This method is used for obtaining the attributes of the object. @@ -527,6 +531,10 @@ def _get_attrs( recursive (:obj:`bool`): If :obj:`True`, will convert any ``TelegramObjects`` (if found) in the attributes to a dictionary. Else, preserves it as an object itself. remove_bot (:obj:`bool`): Whether the bot should be included in the result. + convert_default_vault (:obj:`bool`): Whether :class:`telegram.DefaultValue` should be + converted to its true value. This is necessary when converting to a dictionary for + end users since DefaultValue is used in some classes that work with + `tg.ext.defaults` (like `LinkPreviewOptions`) Returns: :obj:`dict`: A dict where the keys are attribute names and values are their values. @@ -534,7 +542,12 @@ def _get_attrs( data = {} for key in self._get_attrs_names(include_private=include_private): - value = getattr(self, key, None) + value = ( + DefaultValue.get_value(getattr(self, key, None)) + if convert_default_vault + else getattr(self, key, None) + ) + if value is not None: if recursive and hasattr(value, "to_dict"): data[key] = value.to_dict(recursive=True) diff --git a/telegram/_update.py b/telegram/_update.py index 9ba4c7734f0..8de2c2d990a 100644 --- a/telegram/_update.py +++ b/telegram/_update.py @@ -22,16 +22,19 @@ from telegram import constants from telegram._callbackquery import CallbackQuery +from telegram._chatboost import ChatBoostRemoved, ChatBoostUpdated from telegram._chatjoinrequest import ChatJoinRequest from telegram._chatmemberupdated import ChatMemberUpdated from telegram._choseninlineresult import ChosenInlineResult from telegram._inline.inlinequery import InlineQuery from telegram._message import Message +from telegram._messagereactionupdated import MessageReactionCountUpdated, MessageReactionUpdated from telegram._payment.precheckoutquery import PreCheckoutQuery from telegram._payment.shippingquery import ShippingQuery from telegram._poll import Poll, PollAnswer from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict +from telegram._utils.warnings import warn if TYPE_CHECKING: from telegram import Bot, Chat, User @@ -58,11 +61,13 @@ class Update(TelegramObject): message (:class:`telegram.Message`, optional): New incoming message of any kind - text, photo, sticker, etc. edited_message (:class:`telegram.Message`, optional): New version of a message that is - known to the bot and was edited. + known to the bot and was edited. This update may at times be triggered by changes to + message fields that are either unavailable or not actively used by your bot. channel_post (:class:`telegram.Message`, optional): New incoming channel post of any kind - text, photo, sticker, etc. edited_channel_post (:class:`telegram.Message`, optional): New version of a channel post - that is known to the bot and was edited. + that is known to the bot and was edited. This update may at times be triggered by + changes to message fields that are either unavailable or not actively used by your bot. inline_query (:class:`telegram.InlineQuery`, optional): New incoming inline query. chosen_inline_result (:class:`telegram.ChosenInlineResult`, optional): The result of an inline query that was chosen by a user and sent to their chat partner. @@ -72,7 +77,7 @@ class Update(TelegramObject): pre_checkout_query (:class:`telegram.PreCheckoutQuery`, optional): New incoming pre-checkout query. Contains full information about checkout. poll (:class:`telegram.Poll`, optional): New poll state. Bots receive only updates about - stopped polls and polls, which are sent by the bot. + manually stopped polls and polls, which are sent by the bot. poll_answer (:class:`telegram.PollAnswer`, optional): A user changed their answer in a non-anonymous poll. Bots receive new votes only in polls that were sent by the bot itself. @@ -96,6 +101,39 @@ class Update(TelegramObject): receive these updates. .. versionadded:: 13.8 + + chat_boost (:class:`telegram.ChatBoostUpdated`, optional): A chat boost was added or + changed. The bot must be an administrator in the chat to receive these updates. + + .. versionadded:: NEXT.VERSION + + removed_chat_boost (:class:`telegram.ChatBoostRemoved`, optional): A boost was removed from + a chat. The bot must be an administrator in the chat to receive these updates. + + .. versionadded:: NEXT.VERSION + + message_reaction (:class:`telegram.MessageReactionUpdated`, optional): A reaction to a + message was changed by a user. The bot must be an administrator in the chat and must + explicitly specify :attr:`MESSAGE_REACTION` in the list of + :paramref:`telegram.ext.Application.run_polling.allowed_updates` to receive these + updates (see :meth:`telegram.Bot.get_updates`, :meth:`telegram.Bot.set_webhook`, + :meth:`telegram.ext.Application.run_polling` and + :meth:`telegram.ext.Application.run_webhook`). The update isn't received for reactions + set by bots. + + .. versionadded:: NEXT.VERSION + + message_reaction_count (:class:`telegram.MessageReactionCountUpdated`, optional): Reactions + to a message with anonymous reactions were changed. The bot must be an administrator in + the chat and must explicitly specify :attr:`MESSAGE_REACTION_COUNT` in the list of + :paramref:`telegram.ext.Application.run_polling.allowed_updates` to receive these + updates (see :meth:`telegram.Bot.get_updates`, :meth:`telegram.Bot.set_webhook`, + :meth:`telegram.ext.Application.run_polling` and + :meth:`telegram.ext.Application.run_webhook`). The updates are grouped and can be sent + with delay up to a few minutes. + + .. versionadded:: NEXT.VERSION + Attributes: update_id (:obj:`int`): The update's unique identifier. Update identifiers start from a certain positive number and increase sequentially. This ID becomes especially handy if @@ -106,11 +144,13 @@ class Update(TelegramObject): message (:class:`telegram.Message`): Optional. New incoming message of any kind - text, photo, sticker, etc. edited_message (:class:`telegram.Message`): Optional. New version of a message that is - known to the bot and was edited. + known to the bot and was edited. This update may at times be triggered by changes to + message fields that are either unavailable or not actively used by your bot. channel_post (:class:`telegram.Message`): Optional. New incoming channel post of any kind - text, photo, sticker, etc. edited_channel_post (:class:`telegram.Message`): Optional. New version of a channel post - that is known to the bot and was edited. + that is known to the bot and was edited. This update may at times be triggered by + changes to message fields that are either unavailable or not actively used by your bot. inline_query (:class:`telegram.InlineQuery`): Optional. New incoming inline query. chosen_inline_result (:class:`telegram.ChosenInlineResult`): Optional. The result of an inline query that was chosen by a user and sent to their chat partner. @@ -123,7 +163,7 @@ class Update(TelegramObject): pre_checkout_query (:class:`telegram.PreCheckoutQuery`): Optional. New incoming pre-checkout query. Contains full information about checkout. poll (:class:`telegram.Poll`): Optional. New poll state. Bots receive only updates about - stopped polls and polls, which are sent by the bot. + manually stopped polls and polls, which are sent by the bot. poll_answer (:class:`telegram.PollAnswer`): Optional. A user changed their answer in a non-anonymous poll. Bots receive new votes only in polls that were sent by the bot itself. @@ -148,6 +188,37 @@ class Update(TelegramObject): .. versionadded:: 13.8 + chat_boost (:class:`telegram.ChatBoostUpdated`): Optional. A chat boost was added or + changed. The bot must be an administrator in the chat to receive these updates. + + .. versionadded:: NEXT.VERSION + + removed_chat_boost (:class:`telegram.ChatBoostRemoved`): Optional. A boost was removed from + a chat. The bot must be an administrator in the chat to receive these updates. + + .. versionadded:: NEXT.VERSION + + message_reaction (:class:`telegram.MessageReactionUpdated`): Optional. A reaction to a + message was changed by a user. The bot must be an administrator in the chat and must + explicitly specify :attr:`MESSAGE_REACTION` in the list of + :paramref:`telegram.ext.Application.run_polling.allowed_updates` to receive these + updates (see :meth:`telegram.Bot.get_updates`, :meth:`telegram.Bot.set_webhook`, + :meth:`telegram.ext.Application.run_polling` and + :meth:`telegram.ext.Application.run_webhook`). The update isn't received for reactions + set by bots. + + .. versionadded:: NEXT.VERSION + + message_reaction_count (:class:`telegram.MessageReactionCountUpdated`): Optional. Reactions + to a message with anonymous reactions were changed. The bot must be an administrator in + the chat and must explicitly specify :attr:`MESSAGE_REACTION_COUNT` in the list of + :paramref:`telegram.ext.Application.run_polling.allowed_updates` to receive these + updates (see :meth:`telegram.Bot.get_updates`, :meth:`telegram.Bot.set_webhook`, + :meth:`telegram.ext.Application.run_polling` and + :meth:`telegram.ext.Application.run_webhook`). The updates are grouped and can be sent + with delay up to a few minutes. + + .. versionadded:: NEXT.VERSION """ __slots__ = ( @@ -156,6 +227,7 @@ class Update(TelegramObject): "_effective_user", "callback_query", "channel_post", + "chat_boost", "chat_join_request", "chat_member", "chosen_inline_result", @@ -163,10 +235,13 @@ class Update(TelegramObject): "edited_message", "inline_query", "message", + "message_reaction", + "message_reaction_count", "my_chat_member", "poll", "poll_answer", "pre_checkout_query", + "removed_chat_boost", "shipping_query", "update_id", ) @@ -227,6 +302,22 @@ class Update(TelegramObject): """:const:`telegram.constants.UpdateType.CHAT_JOIN_REQUEST` .. versionadded:: 13.8""" + CHAT_BOOST: Final[str] = constants.UpdateType.CHAT_BOOST + """:const:`telegram.constants.UpdateType.CHAT_BOOST` + + .. versionadded:: NEXT.VERSION""" + REMOVED_CHAT_BOOST: Final[str] = constants.UpdateType.REMOVED_CHAT_BOOST + """:const:`telegram.constants.UpdateType.REMOVED_CHAT_BOOST` + + .. versionadded:: NEXT.VERSION""" + MESSAGE_REACTION: Final[str] = constants.UpdateType.MESSAGE_REACTION + """:const:`telegram.constants.UpdateType.MESSAGE_REACTION` + + .. versionadded:: NEXT.VERSION""" + MESSAGE_REACTION_COUNT: Final[str] = constants.UpdateType.MESSAGE_REACTION_COUNT + """:const:`telegram.constants.UpdateType.MESSAGE_REACTION_COUNT` + + .. versionadded:: NEXT.VERSION""" ALL_TYPES: Final[List[str]] = list(constants.UpdateType) """List[:obj:`str`]: A list of all available update types. @@ -249,6 +340,10 @@ def __init__( my_chat_member: Optional[ChatMemberUpdated] = None, chat_member: Optional[ChatMemberUpdated] = None, chat_join_request: Optional[ChatJoinRequest] = None, + chat_boost: Optional[ChatBoostUpdated] = None, + removed_chat_boost: Optional[ChatBoostRemoved] = None, + message_reaction: Optional[MessageReactionUpdated] = None, + message_reaction_count: Optional[MessageReactionCountUpdated] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -270,6 +365,10 @@ def __init__( self.my_chat_member: Optional[ChatMemberUpdated] = my_chat_member self.chat_member: Optional[ChatMemberUpdated] = chat_member self.chat_join_request: Optional[ChatJoinRequest] = chat_join_request + self.chat_boost: Optional[ChatBoostUpdated] = chat_boost + self.removed_chat_boost: Optional[ChatBoostRemoved] = removed_chat_boost + self.message_reaction: Optional[MessageReactionUpdated] = message_reaction + self.message_reaction_count: Optional[MessageReactionCountUpdated] = message_reaction_count self._effective_user: Optional[User] = None self._effective_chat: Optional[Chat] = None @@ -284,7 +383,16 @@ def effective_user(self) -> Optional["User"]: """ :class:`telegram.User`: The user that sent this update, no matter what kind of update this is. If no user is associated with this update, this gives :obj:`None`. This is the case - if :attr:`channel_post`, :attr:`edited_channel_post` or :attr:`poll` is present. + if any of + + * :attr:`channel_post` + * :attr:`edited_channel_post` + * :attr:`poll` + * :attr:`chat_boost` + * :attr:`removed_chat_boost` + * :attr:`message_reaction_count` + + is present. Example: * If :attr:`message` is present, this will give @@ -330,6 +438,9 @@ def effective_user(self) -> Optional["User"]: elif self.chat_join_request: user = self.chat_join_request.from_user + elif self.message_reaction: + user = self.message_reaction.user + self._effective_user = user return user @@ -377,6 +488,18 @@ def effective_chat(self) -> Optional["Chat"]: elif self.chat_join_request: chat = self.chat_join_request.chat + elif self.chat_boost: + chat = self.chat_boost.chat + + elif self.removed_chat_boost: + chat = self.removed_chat_boost.chat + + elif self.message_reaction: + chat = self.message_reaction.chat + + elif self.message_reaction_count: + chat = self.message_reaction_count.chat + self._effective_chat = chat return chat @@ -389,11 +512,18 @@ def effective_message(self) -> Optional[Message]: :attr:`callback_query` (i.e. :attr:`telegram.CallbackQuery.message`) or :obj:`None`, if none of those are present. + Tip: + This property will only ever return objects of type :class:`telegram.Message` or + :obj:`None`, never :class:`telegram.MaybeInaccessibleMessage` or + :class:`telegram.InaccessibleMessage`. + Currently, this is only relevant for :attr:`callback_query`, as + :attr:`telegram.CallbackQuery.message` is the only attribute considered by this + property that can be an object of these types. """ if self._effective_message: return self._effective_message - message = None + message: Optional[Message] = None if self.message: message = self.message @@ -402,7 +532,21 @@ def effective_message(self) -> Optional[Message]: message = self.edited_message elif self.callback_query: - message = self.callback_query.message + if ( + isinstance(cbq_message := self.callback_query.message, Message) + or cbq_message is None + ): + message = cbq_message + else: + warn( + ( + "`update.callback_query` is not `None`, but of type " + f"`{cbq_message.__class__.__name__}`. This is not considered by " + "`Update.effective_message`. Please manually access this attribute " + "if necessary." + ), + stacklevel=2, + ) elif self.channel_post: message = self.channel_post @@ -437,5 +581,13 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Update"]: data["my_chat_member"] = ChatMemberUpdated.de_json(data.get("my_chat_member"), bot) data["chat_member"] = ChatMemberUpdated.de_json(data.get("chat_member"), bot) data["chat_join_request"] = ChatJoinRequest.de_json(data.get("chat_join_request"), bot) + data["chat_boost"] = ChatBoostUpdated.de_json(data.get("chat_boost"), bot) + data["removed_chat_boost"] = ChatBoostRemoved.de_json(data.get("removed_chat_boost"), bot) + data["message_reaction"] = MessageReactionUpdated.de_json( + data.get("message_reaction"), bot + ) + data["message_reaction_count"] = MessageReactionCountUpdated.de_json( + data.get("message_reaction_count"), bot + ) return super().de_json(data=data, bot=bot) diff --git a/telegram/_user.py b/telegram/_user.py index 5a0e29bdab8..95c1f055661 100644 --- a/telegram/_user.py +++ b/telegram/_user.py @@ -41,12 +41,15 @@ InputMediaPhoto, InputMediaVideo, LabeledPrice, + LinkPreviewOptions, Location, Message, MessageEntity, MessageId, PhotoSize, + ReplyParameters, Sticker, + UserChatBoosts, UserProfilePhotos, Venue, Video, @@ -391,6 +394,8 @@ async def send_message( entities: Optional[Sequence["MessageEntity"]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -416,8 +421,10 @@ async def send_message( text=text, parse_mode=parse_mode, disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, allow_sending_without_reply=allow_sending_without_reply, entities=entities, @@ -430,6 +437,70 @@ async def send_message( api_kwargs=api_kwargs, ) + async def delete_message( + self, + message_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: Optional[JSONDict] = None, + ) -> bool: + """Shortcut for:: + + await bot.delete_message(update.effective_user.id, *argss, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.delete_message`. + + .. versionadded:: NEXT.VERSION + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().delete_message( + chat_id=self.id, + message_id=message_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def delete_messages( + self, + message_ids: Sequence[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: Optional[JSONDict] = None, + ) -> bool: + """Shortcut for:: + + await bot.delete_messages(update.effective_user.id, *argss, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.delete_messages`. + + .. versionadded:: NEXT.VERSION + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().delete_messages( + chat_id=self.id, + message_ids=message_ids, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def send_photo( self, photo: Union[FileInput, "PhotoSize"], @@ -443,6 +514,7 @@ async def send_photo( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, has_spoiler: Optional[bool] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -470,6 +542,7 @@ async def send_photo( caption=caption, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, parse_mode=parse_mode, allow_sending_without_reply=allow_sending_without_reply, @@ -495,6 +568,7 @@ async def send_media_group( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -524,6 +598,7 @@ async def send_media_group( media=media, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -553,6 +628,7 @@ async def send_audio( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -583,6 +659,7 @@ async def send_audio( caption=caption, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, parse_mode=parse_mode, allow_sending_without_reply=allow_sending_without_reply, @@ -648,6 +725,7 @@ async def send_contact( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, contact: Optional["Contact"] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -676,6 +754,7 @@ async def send_contact( last_name=last_name, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -698,6 +777,7 @@ async def send_dice( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -722,6 +802,7 @@ async def send_dice( chat_id=self.id, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -748,6 +829,7 @@ async def send_document( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -776,6 +858,7 @@ async def send_document( caption=caption, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -800,6 +883,7 @@ async def send_game( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -825,6 +909,7 @@ async def send_game( game_short_name=game_short_name, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -865,6 +950,7 @@ async def send_invoice( suggested_tip_amounts: Optional[Sequence[int]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -915,6 +1001,7 @@ async def send_invoice( is_flexible=is_flexible, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, provider_data=provider_data, send_phone_number_to_provider=send_phone_number_to_provider, @@ -945,6 +1032,7 @@ async def send_location( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, location: Optional["Location"] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -972,6 +1060,7 @@ async def send_location( longitude=longitude, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1005,6 +1094,7 @@ async def send_animation( message_thread_id: Optional[int] = None, has_spoiler: Optional[bool] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1036,6 +1126,7 @@ async def send_animation( parse_mode=parse_mode, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1061,6 +1152,7 @@ async def send_sticker( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, emoji: Optional[str] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1086,6 +1178,7 @@ async def send_sticker( sticker=sticker, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1116,6 +1209,7 @@ async def send_video( message_thread_id: Optional[int] = None, has_spoiler: Optional[bool] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1144,6 +1238,7 @@ async def send_video( caption=caption, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1179,6 +1274,7 @@ async def send_venue( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, venue: Optional["Venue"] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1209,6 +1305,7 @@ async def send_venue( foursquare_id=foursquare_id, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1236,6 +1333,7 @@ async def send_video_note( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1264,6 +1362,7 @@ async def send_video_note( length=length, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1290,6 +1389,7 @@ async def send_voice( caption_entities: Optional[Sequence["MessageEntity"]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1318,6 +1418,7 @@ async def send_voice( caption=caption, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1352,6 +1453,7 @@ async def send_poll( explanation_entities: Optional[Sequence["MessageEntity"]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1383,6 +1485,7 @@ async def send_poll( is_closed=is_closed, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1412,6 +1515,7 @@ async def send_copy( reply_markup: Optional[ReplyMarkup] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1441,6 +1545,7 @@ async def send_copy( caption_entities=caption_entities, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, read_timeout=read_timeout, @@ -1465,6 +1570,7 @@ async def copy_message( reply_markup: Optional[ReplyMarkup] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1494,6 +1600,7 @@ async def copy_message( caption_entities=caption_entities, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, read_timeout=read_timeout, @@ -1505,6 +1612,267 @@ async def copy_message( message_thread_id=message_thread_id, ) + async def send_copies( + self, + from_chat_id: Union[str, int], + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: Optional[int] = None, + remove_caption: Optional[bool] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + ) -> Tuple["MessageId", ...]: + """Shortcut for:: + + await bot.copy_messages(chat_id=update.effective_user.id, *argss, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.copy_messages`. + + .. seealso:: :meth:`copy_message`, :meth:`send_copy`, :meth:`copy_messages`. + + .. versionadded:: NEXT.VERSION + + Returns: + Tuple[:class:`telegram.MessageId`]: On success, a tuple of :class:`~telegram.MessageId` + of the sent messages is returned. + + """ + return await self.get_bot().copy_messages( + chat_id=self.id, + from_chat_id=from_chat_id, + message_ids=message_ids, + disable_notification=disable_notification, + protect_content=protect_content, + message_thread_id=message_thread_id, + remove_caption=remove_caption, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def copy_messages( + self, + chat_id: Union[str, int], + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: Optional[int] = None, + remove_caption: Optional[bool] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + ) -> Tuple["MessageId", ...]: + """Shortcut for:: + + await bot.copy_messages(from_chat_id=update.effective_user.id, *argss, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.copy_messages`. + + .. seealso:: :meth:`copy_message`, :meth:`send_copy`, :meth:`send_copies`. + + .. versionadded:: NEXT.VERSION + + Returns: + Tuple[:class:`telegram.MessageId`]: On success, a tuple of :class:`~telegram.MessageId` + of the sent messages is returned. + + """ + return await self.get_bot().copy_messages( + from_chat_id=self.id, + chat_id=chat_id, + message_ids=message_ids, + disable_notification=disable_notification, + protect_content=protect_content, + message_thread_id=message_thread_id, + remove_caption=remove_caption, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def forward_from( + self, + from_chat_id: Union[str, int], + message_id: int, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: Optional[int] = 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: Optional[JSONDict] = None, + ) -> "Message": + """Shortcut for:: + + await bot.forward_message(chat_id=update.effective_user.id, *argss, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.forward_message`. + + .. seealso:: :meth:`forward_to`, :meth:`forward_messages_from`, :meth:`forward_messages_to` + + .. versionadded:: 20.0 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().forward_message( + chat_id=self.id, + from_chat_id=from_chat_id, + message_id=message_id, + disable_notification=disable_notification, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + protect_content=protect_content, + message_thread_id=message_thread_id, + ) + + async def forward_to( + self, + chat_id: Union[int, str], + message_id: int, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: Optional[int] = 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: Optional[JSONDict] = None, + ) -> "Message": + """Shortcut for:: + + await bot.forward_message(from_chat_id=update.effective_user.id, *argss, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.forward_message`. + + .. seealso:: :meth:`forward_from`, :meth:`forward_messages_from`, + :meth:`forward_messages_to` + + .. versionadded:: 20.0 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().forward_message( + from_chat_id=self.id, + chat_id=chat_id, + message_id=message_id, + disable_notification=disable_notification, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + protect_content=protect_content, + message_thread_id=message_thread_id, + ) + + async def forward_messages_from( + self, + from_chat_id: Union[str, int], + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: Optional[int] = 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: Optional[JSONDict] = None, + ) -> Tuple["MessageId", ...]: + """Shortcut for:: + + await bot.forward_messages(chat_id=update.effective_user.id, *argss, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.forward_messages`. + + .. seealso:: :meth:`forward_to`, :meth:`forward_from`, :meth:`forward_messages_to`. + + .. versionadded:: NEXT.VERSION + + Returns: + Tuple[:class:`telegram.MessageId`]: On success, a tuple of :class:`~telegram.MessageId` + of sent messages is returned. + + """ + return await self.get_bot().forward_messages( + chat_id=self.id, + from_chat_id=from_chat_id, + message_ids=message_ids, + disable_notification=disable_notification, + protect_content=protect_content, + message_thread_id=message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def forward_messages_to( + self, + chat_id: Union[int, str], + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: Optional[int] = 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: Optional[JSONDict] = None, + ) -> Tuple["MessageId", ...]: + """Shortcut for:: + + await bot.forward_messages(from_chat_id=update.effective_user.id, *argss, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.forward_messages`. + + .. seealso:: :meth:`forward_from`, :meth:`forward_to`, :meth:`forward_messages_from`. + + .. versionadded:: NEXT.VERSION + + Returns: + Tuple[:class:`telegram.MessageId`]: On success, a tuple of :class:`~telegram.MessageId` + of sent messages is returned. + + """ + return await self.get_bot().forward_messages( + from_chat_id=self.id, + chat_id=chat_id, + message_ids=message_ids, + disable_notification=disable_notification, + protect_content=protect_content, + message_thread_id=message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def approve_join_request( self, chat_id: Union[int, str], @@ -1589,7 +1957,7 @@ async def set_menu_button( ) -> bool: """Shortcut for:: - await bot.set_chat_menu_button(chat_id=update.effective_chat.id, *args, **kwargs) + await bot.set_chat_menu_button(chat_id=update.effective_user.id, *argss, **kwargs) For the documentation of the arguments, please see :meth:`telegram.Bot.set_chat_menu_button`. @@ -1648,3 +2016,35 @@ async def get_menu_button( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) + + async def get_chat_boosts( + self, + chat_id: Union[int, str], + *, + 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: Optional[JSONDict] = None, + ) -> "UserChatBoosts": + """Shortcut for:: + + await bot.get_user_chat_boosts(user_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.get_user_chat_boosts`. + + .. versionadded:: NEXT.VERSION + + Returns: + :class:`telegram.UserChatBoosts`: On success, returns the boosts applied by the user. + """ + return await self.get_bot().get_user_chat_boosts( + chat_id=chat_id, + user_id=self.id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) diff --git a/telegram/_utils/warnings_transition.py b/telegram/_utils/warnings_transition.py index c3799a125eb..b4e56d600d1 100644 --- a/telegram/_utils/warnings_transition.py +++ b/telegram/_utils/warnings_transition.py @@ -25,10 +25,30 @@ """ from typing import Any, Callable, Type +from telegram._linkpreviewoptions import LinkPreviewOptions +from telegram._utils.defaultvalue import DefaultValue +from telegram._utils.types import ODVInput from telegram._utils.warnings import warn from telegram.warnings import PTBDeprecationWarning +def build_deprecation_warning_message( + deprecated_name: str, + new_name: str, + object_type: str, + bot_api_version: str, +) -> str: + """Builds a warning message for the transition in API when an object is renamed. + + Returns a warning message that can be used in `warn` function. + """ + return ( + f"The {object_type} '{deprecated_name}' was renamed to '{new_name}' in Bot API " + f"{bot_api_version}. We recommend using '{new_name}' instead of " + f"'{deprecated_name}'." + ) + + # Narrower type hints will cause linting errors and/or circular imports. # We'll use `Any` here and put type hints in the calling code. def warn_about_deprecated_arg_return_new_arg( @@ -50,11 +70,15 @@ def warn_about_deprecated_arg_return_new_arg( different. """ if deprecated_arg and new_arg and deprecated_arg != new_arg: + base_message = build_deprecation_warning_message( + deprecated_name=deprecated_arg_name, + new_name=new_arg_name, + object_type="parameter", + bot_api_version=bot_api_version, + ) raise ValueError( f"You passed different entities as '{deprecated_arg_name}' and '{new_arg_name}'. " - f"The parameter '{deprecated_arg_name}' was renamed to '{new_arg_name}' in Bot API " - f"{bot_api_version}. We recommend using '{new_arg_name}' instead of " - f"'{deprecated_arg_name}'." + f"{base_message}" ) if deprecated_arg: @@ -69,6 +93,28 @@ def warn_about_deprecated_arg_return_new_arg( return new_arg +def warn_for_link_preview_options( + disable_web_page_preview: ODVInput[bool], link_preview_options: ODVInput[LinkPreviewOptions] +) -> ODVInput[LinkPreviewOptions]: + """Wrapper around warn_about_deprecated_arg_return_new_arg. Takes care of converting + disable_web_page_preview to LinkPreviewOptions. + """ + warn_about_deprecated_arg_return_new_arg( + deprecated_arg=disable_web_page_preview, + new_arg=link_preview_options, + deprecated_arg_name="disable_web_page_preview", + new_arg_name="link_preview_options", + bot_api_version="7.0", + stacklevel=2, + ) + + # Convert to LinkPreviewOptions: + if not isinstance(disable_web_page_preview, DefaultValue): + link_preview_options = LinkPreviewOptions(is_disabled=disable_web_page_preview) + + return link_preview_options + + def warn_about_deprecated_attr_in_property( deprecated_attr_name: str, new_attr_name: str, diff --git a/telegram/constants.py b/telegram/constants.py index fa3e8bc9bb8..219234eeb00 100644 --- a/telegram/constants.py +++ b/telegram/constants.py @@ -35,12 +35,16 @@ "BOT_API_VERSION", "BOT_API_VERSION_INFO", "SUPPORTED_WEBHOOK_PORTS", + "ZERO_DATE", + "AccentColor", "BotCommandLimit", "BotCommandScopeType", "BotDescriptionLimit", "BotNameLimit", + "BulkRequestLimit", "CallbackQueryLimit", "ChatAction", + "ChatBoostSources", "ChatID", "ChatInviteLinkLimit", "ChatLimit", @@ -55,6 +59,7 @@ "FloodLimit", "ForumIconColor", "ForumTopicLimit", + "GiveawayLimit", "InlineKeyboardButtonLimit", "InlineKeyboardMarkupLimit", "InlineQueryLimit", @@ -63,6 +68,7 @@ "InlineQueryResultsButtonLimit", "InputMediaType", "InvoiceLimit", + "KeyboardButtonRequestUsersLimit", "LocationLimit", "MaskPosition", "MediaGroupLimit", @@ -70,11 +76,15 @@ "MessageAttachmentType", "MessageEntityType", "MessageLimit", + "MessageOriginType", "MessageType", "ParseMode", "PollLimit", "PollType", "PollingLimit", + "ProfileAccentColor", + "ReactionEmoji", + "ReactionType", "ReplyLimit", "StickerFormat", "StickerLimit", @@ -85,9 +95,12 @@ "WebhookLimit", ] +import datetime import sys -from typing import Final, List, NamedTuple +from enum import Enum +from typing import Final, List, NamedTuple, Optional, Tuple +from telegram._utils.datetime import UTC from telegram._utils.enum import IntEnum, StringEnum @@ -109,6 +122,19 @@ def __str__(self) -> str: return f"{self.major}.{self.minor}" +class _AccentColor(NamedTuple): + """A helper class for (profile) accent colors. Since TG doesn't define a class for this and + the behavior is quite different for the different accent colors, we don't make this a public + class. This gives us more flexibility to change the implementation if necessary for future + versions. + """ + + identifier: int + name: Optional[str] = None + light_colors: Tuple[int, ...] = () + dark_colors: Tuple[int, ...] = () + + #: :class:`typing.NamedTuple`: A tuple containing the two components of the version number: # ``major`` and ``minor``. Both values are integers. #: The components can also be accessed by name, so ``BOT_API_VERSION_INFO[0]`` is equivalent @@ -116,7 +142,7 @@ def __str__(self) -> str: #: :data:`telegram.__bot_api_version_info__`. #: #: .. versionadded:: 20.0 -BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=6, minor=9) +BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=7, minor=0) #: :obj:`str`: Telegram Bot API #: version supported by this version of `python-telegram-bot`. Also available as #: :data:`telegram.__bot_api_version__`. @@ -130,6 +156,390 @@ def __str__(self) -> str: #: :paramref:`telegram.Bot.set_webhook.url`. SUPPORTED_WEBHOOK_PORTS: Final[List[int]] = [443, 80, 88, 8443] +#: :obj:`datetime.datetime`, value of unix 0. +#: This date literal is used in :class:`telegram.InaccessibleMessage` +#: +#: .. versionadded:: NEXT.VERSION +ZERO_DATE: Final[datetime.datetime] = datetime.datetime(1970, 1, 1, tzinfo=UTC) + + +class AccentColor(Enum): + """This enum contains the available accent colors for :class:`telegram.Chat.accent_color_id`. + The members of this enum are named tuples with the following attributes: + + - ``identifier`` (:obj:`int`): The identifier of the accent color. + - ``name`` (:obj:`str`): Optional. The name of the accent color. + - ``light_colors`` (Tuple[:obj:`str`]): Optional. The light colors of the accent color as HEX + value. + - ``dark_colors`` (Tuple[:obj:`str`]): Optional. The dark colors of the accent color as HEX + value. + + Since Telegram gives no exact specification for the accent colors, future accent colors might + have a different data type. + + .. versionadded:: NEXT.VERSION + """ + + __slots__ = () + + COLOR_000 = _AccentColor(identifier=0, name="red") + """Accent color 0. This color can be customized by app themes.""" + COLOR_001 = _AccentColor(identifier=1, name="orange") + """Accent color 1. This color can be customized by app themes.""" + COLOR_002 = _AccentColor(identifier=2, name="purple/violet") + """Accent color 2. This color can be customized by app themes.""" + COLOR_003 = _AccentColor(identifier=3, name="green") + """Accent color 3. This color can be customized by app themes.""" + COLOR_004 = _AccentColor(identifier=4, name="cyan") + """Accent color 4. This color can be customized by app themes.""" + COLOR_005 = _AccentColor(identifier=5, name="blue") + """Accent color 5. This color can be customized by app themes.""" + COLOR_006 = _AccentColor(identifier=6, name="pink") + """Accent color 6. This color can be customized by app themes.""" + COLOR_007 = _AccentColor( + identifier=7, light_colors=(0xE15052, 0xF9AE63), dark_colors=(0xFF9380, 0x992F37) + ) + """Accent color 7. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_008 = _AccentColor( + identifier=8, light_colors=(0xE0802B, 0xFAC534), dark_colors=(0xECB04E, 0xC35714) + ) + """Accent color 8. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_009 = _AccentColor( + identifier=9, light_colors=(0xA05FF3, 0xF48FFF), dark_colors=(0xC697FF, 0x5E31C8) + ) + """Accent color 9. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_010 = _AccentColor( + identifier=10, light_colors=(0x27A910, 0xA7DC57), dark_colors=(0xA7EB6E, 0x167E2D) + ) + """Accent color 10. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_011 = _AccentColor( + identifier=11, light_colors=(0x27ACCE, 0x82E8D6), dark_colors=(0x40D8D0, 0x045C7F) + ) + """Accent color 11. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + + COLOR_012 = _AccentColor( + identifier=12, light_colors=(0x3391D4, 0x7DD3F0), dark_colors=(0x52BFFF, 0x0B5494) + ) + """Accent color 12. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_013 = _AccentColor( + identifier=13, light_colors=(0xDD4371, 0xFFBE9F), dark_colors=(0xFF86A6, 0x8E366E) + ) + """Accent color 13. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_014 = _AccentColor( + identifier=14, + light_colors=(0x247BED, 0xF04856, 0xFFFFFF), + dark_colors=(0x3FA2FE, 0xE5424F, 0xFFFFFF), + ) + """Accent color 14. This contains three light colors + + .. raw:: html + +
+
+
+
+
+

+ + and three dark colors + + .. raw:: html + +
+
+
+
+
+

+ """ + + COLOR_015 = _AccentColor( + identifier=15, + light_colors=(0xD67722, 0x1EA011, 0xFFFFFF), + dark_colors=(0xFF905E, 0x32A527, 0xFFFFFF), + ) + """Accent color 15. This contains three light colors + + .. raw:: html + +
+
+
+
+
+

+ + and three dark colors + + .. raw:: html + +
+
+
+
+
+

+ """ + + COLOR_016 = _AccentColor( + identifier=16, + light_colors=(0x179E42, 0xE84A3F, 0xFFFFFF), + dark_colors=(0x66D364, 0xD5444F, 0xFFFFFF), + ) + """Accent color 16. This contains three light colors + + .. raw:: html + +
+
+
+
+
+

+ + and three dark colors + + .. raw:: html + +
+
+
+
+
+

+ """ + + COLOR_017 = _AccentColor( + identifier=17, + light_colors=(0x2894AF, 0x6FC456, 0xFFFFFF), + dark_colors=(0x22BCE2, 0x3DA240, 0xFFFFFF), + ) + """Accent color 17. This contains three light colors + + .. raw:: html + +
+
+
+
+
+

+ + and three dark colors + + .. raw:: html + +
+
+
+
+
+

+ """ + + COLOR_018 = _AccentColor( + identifier=18, + light_colors=(0x0C9AB3, 0xFFAD95, 0xFFE6B5), + dark_colors=(0x22BCE2, 0xFF9778, 0xFFDA6B), + ) + """Accent color 18. This contains three light colors + + .. raw:: html + +
+
+
+
+
+

+ + and three dark colors + + .. raw:: html + +
+
+
+
+
+

+ """ + + COLOR_019 = _AccentColor( + identifier=19, + light_colors=(0x7757D6, 0xF79610, 0xFFDE8E), + dark_colors=(0x9791FF, 0xF2731D, 0xFFDB59), + ) + """Accent color 19. This contains three light colors + + .. raw:: html + +
+
+
+
+
+

+ + and three dark colors + + .. raw:: html + +
+
+
+
+
+

+ """ + + COLOR_020 = _AccentColor( + identifier=20, + light_colors=(0x1585CF, 0xF2AB1D, 0xFFFFFF), + dark_colors=(0x3DA6EB, 0xEEA51D, 0xFFFFFF), + ) + """Accent color 20. This contains three light colors + + .. raw:: html + +
+
+
+
+
+

+ + and three dark colors + + .. raw:: html + +
+
+
+
+
+

+ """ + class BotCommandLimit(IntEnum): """This enum contains limitations for :class:`telegram.BotCommand` and @@ -226,6 +636,22 @@ class BotNameLimit(IntEnum): """ +class BulkRequestLimit(IntEnum): + """This enum contains limitations for :meth:`telegram.Bot.delete_messages`, + :meth:`telegram.Bot.forward_messages` and :meth:`telegram.Bot.copy_messages`. 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 number of messages required for bulk actions.""" + MAX_LIMIT = 100 + """:obj:`int`: Maximum number of messages required for bulk actions.""" + + class CallbackQueryLimit(IntEnum): """This enum contains limitations for :class:`telegram.CallbackQuery`/ :meth:`telegram.Bot.answer_callback_query`. The enum members of this enumeration are instances @@ -275,6 +701,24 @@ class ChatAction(StringEnum): """:obj:`str`: Chat action indicating that the bot is uploading a video note.""" +class ChatBoostSources(StringEnum): + """This enum contains the available sources for a + :class:`Telegram chat boost `. + The enum members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: NEXT.VERSION + """ + + __slots__ = () + + GIFT_CODE = "gift_code" + """:obj:`str`: The source of the chat boost was a Telegram Premium gift code.""" + GIVEAWAY = "giveaway" + """:obj:`str`: The source of the chat boost was a Telegram Premium giveaway.""" + PREMIUM = "premium" + """:obj:`str`: The source of the chat boost was a Telegram Premium subscription/gift.""" + + class ChatID(IntEnum): """This enum contains some special chat IDs. The enum members of this enumeration are instances of :class:`int` and can be treated as such. @@ -666,6 +1110,41 @@ class ForumIconColor(IntEnum): """ +class GiveawayLimit(IntEnum): + """This enum contains limitations for :class:`telegram.Giveaway` and related classes. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: NEXT.VERSION + """ + + __slots__ = () + + MAX_WINNERS = 100 + """:obj:`int`: Maximum number of winners allowed for :class:`telegram.GiveawayWinners.winners`. + """ + + +class KeyboardButtonRequestUsersLimit(IntEnum): + """This enum contains limitations for :class:`telegram.KeyboardButtonRequestUsers`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: NEXT.VERSION + """ + + __slots__ = () + + MIN_QUANTITY = 1 + """:obj:`int`: Minimum value allowed for + :paramref:`~telegram.KeyboardButtonRequestUsers.max_quantity` parameter of + :class:`telegram.KeyboardButtonRequestUsers`. + """ + MAX_QUANTITY = 10 + """:obj:`int`: Maximum value allowed for + :paramref:`~telegram.KeyboardButtonRequestUsers.max_quantity` parameter of + :class:`telegram.KeyboardButtonRequestUsers`. + """ + + class InlineKeyboardButtonLimit(IntEnum): """This enum contains limitations for :class:`telegram.InlineKeyboardButton`. The enum members of this enumeration are instances of :class:`int` and can be treated as such. @@ -1128,6 +1607,11 @@ class MessageEntityType(StringEnum): .. versionadded:: 20.0 """ + BLOCKQUOTE = "blockquote" + """:obj:`str`: Message entities representing a block quotation. + + .. versionadded:: NEXT.VERSION + """ class MessageLimit(IntEnum): @@ -1186,9 +1670,27 @@ class MessageLimit(IntEnum): """ +class MessageOriginType(StringEnum): + """This enum contains the available types of :class:`telegram.MessageOrigin`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: NEXT.VERSION + """ + + __slots__ = () + + USER = "user" + """:obj:`str`: A :class:`telegram.MessageOrigin` who is sent by an user.""" + HIDDEN_USER = "hidden_user" + """:obj:`str`: A :class:`telegram.MessageOrigin` who is sent by a hidden user.""" + CHAT = "chat" + """:obj:`str`: A :class:`telegram.MessageOrigin` who is sent by a chat.""" + CHANNEL = "channel" + """:obj:`str`: A :class:`telegram.MessageOrigin` who is sent by a channel.""" + + class MessageType(StringEnum): - """This enum contains the available types of :class:`telegram.Message` that can be seen - as attachment. The enum + """This enum contains the available types of :class:`telegram.Message`. The enum members of this enumeration are instances of :class:`str` and can be treated as such. .. versionadded:: 20.0 @@ -1199,72 +1701,126 @@ class MessageType(StringEnum): # Make sure that all attachment type constants are also listed in the # MessageAttachmentType Enum! (Enums are not extendable) - # -------------------------------------------------- Attachment types ANIMATION = "animation" """:obj:`str`: Messages with :attr:`telegram.Message.animation`.""" AUDIO = "audio" """:obj:`str`: Messages with :attr:`telegram.Message.audio`.""" + CHANNEL_CHAT_CREATED = "channel_chat_created" + """:obj:`str`: Messages with :attr:`telegram.Message.channel_chat_created`.""" + CHAT_SHARED = "chat_shared" + """:obj:`str`: Messages with :attr:`telegram.Message.chat_shared`. + + .. versionadded:: NEXT.VERSION + """ + CONNECTED_WEBSITE = "connected_website" + """:obj:`str`: Messages with :attr:`telegram.Message.connected_website`.""" CONTACT = "contact" """:obj:`str`: Messages with :attr:`telegram.Message.contact`.""" + DELETE_CHAT_PHOTO = "delete_chat_photo" + """:obj:`str`: Messages with :attr:`telegram.Message.delete_chat_photo`.""" DICE = "dice" """:obj:`str`: Messages with :attr:`telegram.Message.dice`.""" DOCUMENT = "document" """:obj:`str`: Messages with :attr:`telegram.Message.document`.""" + FORUM_TOPIC_CREATED = "forum_topic_created" + """:obj:`str`: Messages with :attr:`telegram.Message.forum_topic_created`. + + .. versionadded:: NEXT.VERSION + """ + FORUM_TOPIC_CLOSED = "forum_topic_closed" + """:obj:`str`: Messages with :attr:`telegram.Message.forum_topic_closed`. + + .. versionadded:: NEXT.VERSION + """ + FORUM_TOPIC_EDITED = "forum_topic_edited" + """:obj:`str`: Messages with :attr:`telegram.Message.forum_topic_edited`. + + .. versionadded:: NEXT.VERSION + """ + FORUM_TOPIC_REOPENED = "forum_topic_reopened" + """:obj:`str`: Messages with :attr:`telegram.Message.forum_topic_reopened`. + + .. versionadded:: NEXT.VERSION + """ GAME = "game" """:obj:`str`: Messages with :attr:`telegram.Message.game`.""" + GENERAL_FORUM_TOPIC_HIDDEN = "general_forum_topic_hidden" + """:obj:`str`: Messages with :attr:`telegram.Message.general_forum_topic_hidden`. + + .. versionadded:: NEXT.VERSION + """ + GENERAL_FORUM_TOPIC_UNHIDDEN = "general_forum_topic_unhidden" + """:obj:`str`: Messages with :attr:`telegram.Message.general_forum_topic_unhidden`. + + .. versionadded:: NEXT.VERSION + """ + GIVEAWAY = "giveaway" + """:obj:`str`: Messages with :attr:`telegram.Message.giveaway`. + + .. versionadded:: NEXT.VERSION + """ + GIVEAWAY_CREATED = "giveaway_created" + """:obj:`str`: Messages with :attr:`telegram.Message.giveaway_created`. + + .. versionadded:: NEXT.VERSION + """ + GIVEAWAY_WINNERS = "giveaway_winners" + """:obj:`str`: Messages with :attr:`telegram.Message.giveaway_winners`. + + .. versionadded:: NEXT.VERSION + """ + GIVEAWAY_COMPLETED = "giveaway_completed" + """:obj:`str`: Messages with :attr:`telegram.Message.giveaway_completed`. + + .. versionadded:: NEXT.VERSION + """ + GROUP_CHAT_CREATED = "group_chat_created" + """:obj:`str`: Messages with :attr:`telegram.Message.group_chat_created`.""" INVOICE = "invoice" """:obj:`str`: Messages with :attr:`telegram.Message.invoice`.""" + LEFT_CHAT_MEMBER = "left_chat_member" + """:obj:`str`: Messages with :attr:`telegram.Message.left_chat_member`.""" LOCATION = "location" """:obj:`str`: Messages with :attr:`telegram.Message.location`.""" + MESSAGE_AUTO_DELETE_TIMER_CHANGED = "message_auto_delete_timer_changed" + """:obj:`str`: Messages with :attr:`telegram.Message.message_auto_delete_timer_changed`.""" + MIGRATE_TO_CHAT_ID = "migrate_to_chat_id" + """:obj:`str`: Messages with :attr:`telegram.Message.migrate_to_chat_id`.""" + NEW_CHAT_MEMBERS = "new_chat_members" + """:obj:`str`: Messages with :attr:`telegram.Message.new_chat_members`.""" + NEW_CHAT_TITLE = "new_chat_title" + """:obj:`str`: Messages with :attr:`telegram.Message.new_chat_title`.""" + NEW_CHAT_PHOTO = "new_chat_photo" + """:obj:`str`: Messages with :attr:`telegram.Message.new_chat_photo`.""" PASSPORT_DATA = "passport_data" """:obj:`str`: Messages with :attr:`telegram.Message.passport_data`.""" PHOTO = "photo" """:obj:`str`: Messages with :attr:`telegram.Message.photo`.""" + PINNED_MESSAGE = "pinned_message" + """:obj:`str`: Messages with :attr:`telegram.Message.pinned_message`.""" POLL = "poll" """:obj:`str`: Messages with :attr:`telegram.Message.poll`.""" + PROXIMITY_ALERT_TRIGGERED = "proximity_alert_triggered" + """:obj:`str`: Messages with :attr:`telegram.Message.proximity_alert_triggered`.""" STICKER = "sticker" """:obj:`str`: Messages with :attr:`telegram.Message.sticker`.""" STORY = "story" """:obj:`str`: Messages with :attr:`telegram.Message.story`.""" + SUPERGROUP_CHAT_CREATED = "supergroup_chat_created" + """:obj:`str`: Messages with :attr:`telegram.Message.supergroup_chat_created`.""" SUCCESSFUL_PAYMENT = "successful_payment" """:obj:`str`: Messages with :attr:`telegram.Message.successful_payment`.""" - VIDEO = "video" - """:obj:`str`: Messages with :attr:`telegram.Message.video`.""" - VIDEO_NOTE = "video_note" - """:obj:`str`: Messages with :attr:`telegram.Message.video_note`.""" - VOICE = "voice" - """:obj:`str`: Messages with :attr:`telegram.Message.voice`.""" - VENUE = "venue" - """:obj:`str`: Messages with :attr:`telegram.Message.venue`.""" - # -------------------------------------------------- Other types TEXT = "text" """:obj:`str`: Messages with :attr:`telegram.Message.text`.""" - NEW_CHAT_MEMBERS = "new_chat_members" - """:obj:`str`: Messages with :attr:`telegram.Message.new_chat_members`.""" - LEFT_CHAT_MEMBER = "left_chat_member" - """:obj:`str`: Messages with :attr:`telegram.Message.left_chat_member`.""" - NEW_CHAT_TITLE = "new_chat_title" - """:obj:`str`: Messages with :attr:`telegram.Message.new_chat_title`.""" - NEW_CHAT_PHOTO = "new_chat_photo" - """:obj:`str`: Messages with :attr:`telegram.Message.new_chat_photo`.""" - DELETE_CHAT_PHOTO = "delete_chat_photo" - """:obj:`str`: Messages with :attr:`telegram.Message.delete_chat_photo`.""" - GROUP_CHAT_CREATED = "group_chat_created" - """:obj:`str`: Messages with :attr:`telegram.Message.group_chat_created`.""" - SUPERGROUP_CHAT_CREATED = "supergroup_chat_created" - """:obj:`str`: Messages with :attr:`telegram.Message.supergroup_chat_created`.""" - CHANNEL_CHAT_CREATED = "channel_chat_created" - """:obj:`str`: Messages with :attr:`telegram.Message.channel_chat_created`.""" - MESSAGE_AUTO_DELETE_TIMER_CHANGED = "message_auto_delete_timer_changed" - """:obj:`str`: Messages with :attr:`telegram.Message.message_auto_delete_timer_changed`.""" - MIGRATE_TO_CHAT_ID = "migrate_to_chat_id" - """:obj:`str`: Messages with :attr:`telegram.Message.migrate_to_chat_id`.""" - MIGRATE_FROM_CHAT_ID = "migrate_from_chat_id" - """:obj:`str`: Messages with :attr:`telegram.Message.migrate_from_chat_id`.""" - PINNED_MESSAGE = "pinned_message" - """:obj:`str`: Messages with :attr:`telegram.Message.pinned_message`.""" - PROXIMITY_ALERT_TRIGGERED = "proximity_alert_triggered" - """:obj:`str`: Messages with :attr:`telegram.Message.proximity_alert_triggered`.""" + USERS_SHARED = "users_shared" + """:obj:`str`: Messages with :attr:`telegram.Message.users_shared`. + + .. versionadded:: NEXT.VERSION + """ + VENUE = "venue" + """:obj:`str`: Messages with :attr:`telegram.Message.venue`.""" + VIDEO = "video" + """:obj:`str`: Messages with :attr:`telegram.Message.video`.""" VIDEO_CHAT_SCHEDULED = "video_chat_scheduled" """:obj:`str`: Messages with :attr:`telegram.Message.video_chat_scheduled`.""" VIDEO_CHAT_STARTED = "video_chat_started" @@ -1273,6 +1829,20 @@ class MessageType(StringEnum): """:obj:`str`: Messages with :attr:`telegram.Message.video_chat_ended`.""" VIDEO_CHAT_PARTICIPANTS_INVITED = "video_chat_participants_invited" """:obj:`str`: Messages with :attr:`telegram.Message.video_chat_participants_invited`.""" + VIDEO_NOTE = "video_note" + """:obj:`str`: Messages with :attr:`telegram.Message.video_note`.""" + VOICE = "voice" + """:obj:`str`: Messages with :attr:`telegram.Message.voice`.""" + WEB_APP_DATA = "web_app_data" + """:obj:`str`: Messages with :attr:`telegram.Message.web_app_data`. + + .. versionadded:: NEXT.VERSION + """ + WRITE_ACCESS_ALLOWED = "write_access_allowed" + """:obj:`str`: Messages with :attr:`telegram.Message.write_access_allowed`. + + .. versionadded:: NEXT.VERSION + """ class PollingLimit(IntEnum): @@ -1294,6 +1864,316 @@ class PollingLimit(IntEnum): """ +class ProfileAccentColor(Enum): + """This enum contains the available accent colors for + :class:`telegram.Chat.profile_accent_color_id`. + The members of this enum are named tuples with the following attributes: + + - ``identifier`` (:obj:`int`): The identifier of the accent color. + - ``name`` (:obj:`str`): Optional. The name of the accent color. + - ``light_colors`` (Tuple[:obj:`str`]): Optional. The light colors of the accent color as HEX + value. + - ``dark_colors`` (Tuple[:obj:`str`]): Optional. The dark colors of the accent color as HEX + value. + + Since Telegram gives no exact specification for the accent colors, future accent colors might + have a different data type. + + .. versionadded:: NEXT.VERSION + """ + + __slots__ = () + + COLOR_000 = _AccentColor(identifier=0, light_colors=(0xBA5650,), dark_colors=(0x9C4540,)) + """Accent color 0. This contains one light color + + .. raw:: html + +
+
+ + and one dark color + + .. raw:: html + +
+
+ """ + COLOR_001 = _AccentColor(identifier=1, light_colors=(0xC27C3E,), dark_colors=(0x945E2C,)) + """Accent color 1. This contains one light color + + .. raw:: html + +
+
+ + and one dark color + + .. raw:: html + +
+
+ """ + COLOR_002 = _AccentColor(identifier=2, light_colors=(0x956AC8,), dark_colors=(0x715099,)) + """Accent color 2. This contains one light color + + .. raw:: html + +
+
+ + and one dark color + + .. raw:: html + +
+
+ """ + COLOR_003 = _AccentColor(identifier=3, light_colors=(0x49A355,), dark_colors=(0x33713B,)) + """Accent color 3. This contains one light color + + .. raw:: html + +
+
+ + and one dark color + + .. raw:: html + +
+
+ """ + COLOR_004 = _AccentColor(identifier=4, light_colors=(0x3E97AD,), dark_colors=(0x387E87,)) + """Accent color 4. This contains one light color + + .. raw:: html + +
+
+ + and one dark color + + .. raw:: html + +
+
+ """ + COLOR_005 = _AccentColor(identifier=5, light_colors=(0x5A8FBB,), dark_colors=(0x477194,)) + """Accent color 5. This contains one light color + + .. raw:: html + +
+
+ + and one dark color + + .. raw:: html + +
+
+ """ + COLOR_006 = _AccentColor(identifier=6, light_colors=(0xB85378,), dark_colors=(0x944763,)) + """Accent color 6. This contains one light color + + .. raw:: html + +
+
+ + and one dark color + + .. raw:: html + +
+
+ """ + COLOR_007 = _AccentColor(identifier=7, light_colors=(0x7F8B95,), dark_colors=(0x435261,)) + """Accent color 7. This contains one light color + + .. raw:: html + +
+
+ + and one dark color + + .. raw:: html + +
+
+ """ + COLOR_008 = _AccentColor( + identifier=8, light_colors=(0xC9565D, 0xD97C57), dark_colors=(0x994343, 0xAC583E) + ) + """Accent color 8. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_009 = _AccentColor( + identifier=9, light_colors=(0xCF7244, 0xCC9433), dark_colors=(0x8F552F, 0xA17232) + ) + """Accent color 9. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_010 = _AccentColor( + identifier=10, light_colors=(0x9662D4, 0xB966B6), dark_colors=(0x634691, 0x9250A2) + ) + """Accent color 10. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_011 = _AccentColor( + identifier=11, light_colors=(0x3D9755, 0x89A650), dark_colors=(0x296A43, 0x5F8F44) + ) + """Accent color 11. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_012 = _AccentColor( + identifier=12, light_colors=(0x3D95BA, 0x50AD98), dark_colors=(0x306C7C, 0x3E987E) + ) + """Accent color 12. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_013 = _AccentColor( + identifier=13, light_colors=(0x538BC2, 0x4DA8BD), dark_colors=(0x38618C, 0x458BA1) + ) + """Accent color 13. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_014 = _AccentColor( + identifier=14, light_colors=(0xB04F74, 0xD1666D), dark_colors=(0x884160, 0xA65259) + ) + """Accent color 14. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_015 = _AccentColor( + identifier=15, light_colors=(0x637482, 0x7B8A97), dark_colors=(0x53606E, 0x384654) + ) + """Accent color 15. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + + class ReplyLimit(IntEnum): """This enum contains limitations for :class:`telegram.ForceReply` and :class:`telegram.ReplyKeyboardMarkup`. @@ -1589,6 +2469,26 @@ class UpdateType(StringEnum): """:obj:`str`: Updates with :attr:`telegram.Update.chat_member`.""" CHAT_JOIN_REQUEST = "chat_join_request" """:obj:`str`: Updates with :attr:`telegram.Update.chat_join_request`.""" + CHAT_BOOST = "chat_boost" + """:obj:`str`: Updates with :attr:`telegram.Update.chat_boost`. + + .. versionadded:: NEXT.VERSION + """ + REMOVED_CHAT_BOOST = "removed_chat_boost" + """:obj:`str`: Updates with :attr:`telegram.Update.removed_chat_boost`. + + .. versionadded:: NEXT.VERSION + """ + MESSAGE_REACTION = "message_reaction" + """:obj:`str`: Updates with :attr:`telegram.Update.message_reaction`. + + .. versionadded:: NEXT.VERSION + """ + MESSAGE_REACTION_COUNT = "message_reaction_count" + """:obj:`str`: Updates with :attr:`telegram.Update.message_reaction_count`. + + .. versionadded:: NEXT.VERSION + """ class InvoiceLimit(IntEnum): @@ -1754,3 +2654,175 @@ class ForumTopicLimit(IntEnum): * :paramref:`~telegram.Bot.edit_general_forum_topic.name` parameter of :meth:`telegram.Bot.edit_general_forum_topic` """ + + +class ReactionType(StringEnum): + """This enum contains the available types of :class:`telegram.ReactionType`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: NEXT.VERSION + """ + + __slots__ = () + + EMOJI = "emoji" + """:obj:`str`: A :class:`telegram.ReactionType` with a normal emoji.""" + CUSTOM_EMOJI = "custom_emoji" + """:obj:`str`: A :class:`telegram.ReactionType` with a custom emoji.""" + + +class ReactionEmoji(StringEnum): + """This enum contains the available emojis of :class:`telegram.ReactionTypeEmoji`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: NEXT.VERSION + """ + + __slots__ = () + + THUMBS_UP = "👍" + """:obj:`str`: Thumbs Up""" + THUMBS_DOWN = "👎" + """:obj:`str`: Thumbs Down""" + RED_HEART = "❤" + """:obj:`str`: Red Heart""" + FIRE = "🔥" + """:obj:`str`: Fire""" + SMILING_FACE_WITH_HEARTS = "🥰" + """:obj:`str`: Smiling Face with Hearts""" + CLAPPING_HANDS = "👏" + """:obj:`str`: Clapping Hands""" + GRINNING_FACE_WITH_SMILING_EYES = "😁" + """:obj:`str`: Grinning face with smiling eyes""" + THINKING_FACE = "🤔" + """:obj:`str`: Thinking face""" + SHOCKED_FACE_WITH_EXPLODING_HEAD = "🤯" + """:obj:`str`: Shocked face with exploding head""" + FACE_SCREAMING_IN_FEAR = "😱" + """:obj:`str`: Face screaming in fear""" + SERIOUS_FACE_WITH_SYMBOLS_COVERING_MOUTH = "🤬" + """:obj:`str`: Serious face with symbols covering mouth""" + CRYING_FACE = "😢" + """:obj:`str`: Crying face""" + PARTY_POPPER = "🎉" + """:obj:`str`: Party popper""" + GRINNING_FACE_WITH_STAR_EYES = "🤩" + """:obj:`str`: Grinning face with star eyes""" + FACE_WITH_OPEN_MOUTH_VOMITING = "🤮" + """:obj:`str`: Face with open mouth vomiting""" + PILE_OF_POO = "💩" + """:obj:`str`: Pile of poo""" + PERSON_WITH_FOLDED_HANDS = "🙏" + """:obj:`str`: Person with folded hands""" + OK_HAND_SIGN = "👌" + """:obj:`str`: Ok hand sign""" + DOVE_OF_PEACE = "🕊" + """:obj:`str`: Dove of peace""" + CLOWN_FACE = "🤡" + """:obj:`str`: Clown face""" + YAWNING_FACE = "🥱" + """:obj:`str`: Yawning face""" + FACE_WITH_UNEVEN_EYES_AND_WAVY_MOUTH = "🥴" + """:obj:`str`: Face with uneven eyes and wavy mouth""" + SMILING_FACE_WITH_HEART_SHAPED_EYES = "😍" + """:obj:`str`: Smiling face with heart-shaped eyes""" + SPOUTING_WHALE = "🐳" + """:obj:`str`: Spouting whale""" + HEART_ON_FIRE = "❤️‍🔥" + """:obj:`str`: Heart on fire""" + NEW_MOON_WITH_FACE = "🌚" + """:obj:`str`: New moon with face""" + HOT_DOG = "🌭" + """:obj:`str`: Hot dog""" + HUNDRED_POINTS_SYMBOL = "💯" + """:obj:`str`: Hundred points symbol""" + ROLLING_ON_THE_FLOOR_LAUGHING = "🤣" + """:obj:`str`: Rolling on the floor laughing""" + HIGH_VOLTAGE_SIGN = "⚡" + """:obj:`str`: High voltage sign""" + BANANA = "🍌" + """:obj:`str`: Banana""" + TROPHY = "🏆" + """:obj:`str`: Trophy""" + BROKEN_HEART = "💔" + """:obj:`str`: Broken heart""" + FACE_WITH_ONE_EYEBROW_RAISED = "🤨" + """:obj:`str`: Face with one eyebrow raised""" + NEUTRAL_FACE = "😐" + """:obj:`str`: Neutral face""" + STRAWBERRY = "🍓" + """:obj:`str`: Strawberry""" + BOTTLE_WITH_POPPING_CORK = "🍾" + """:obj:`str`: Bottle with popping cork""" + KISS_MARK = "💋" + """:obj:`str`: Kiss mark""" + REVERSED_HAND_WITH_MIDDLE_FINGER_EXTENDED = "🖕" + """:obj:`str`: Reversed hand with middle finger extended""" + SMILING_FACE_WITH_HORNS = "😈" + """:obj:`str`: Smiling face with horns""" + SLEEPING_FACE = "😴" + """:obj:`str`: Sleeping face""" + LOUDLY_CRYING_FACE = "😭" + """:obj:`str`: Loudly crying face""" + NERD_FACE = "🤓" + """:obj:`str`: Nerd face""" + GHOST = "👻" + """:obj:`str`: Ghost""" + MAN_TECHNOLOGIST = "👨‍💻" + """:obj:`str`: Man Technologist""" + EYES = "👀" + """:obj:`str`: Eyes""" + JACK_O_LANTERN = "🎃" + """:obj:`str`: Jack-o-lantern""" + SEE_NO_EVIL_MONKEY = "🙈" + """:obj:`str`: See-no-evil monkey""" + SMILING_FACE_WITH_HALO = "😇" + """:obj:`str`: Smiling face with halo""" + FEARFUL_FACE = "😨" + """:obj:`str`: Fearful face""" + HANDSHAKE = "🤝" + """:obj:`str`: Handshake""" + WRITING_HAND = "✍" + """:obj:`str`: Writing hand""" + HUGGING_FACE = "🤗" + """:obj:`str`: Hugging face""" + SALUTING_FACE = "🫡" + """:obj:`str`: Saluting face""" + FATHER_CHRISTMAS = "🎅" + """:obj:`str`: Father christmas""" + CHRISTMAS_TREE = "🎄" + """:obj:`str`: Christmas tree""" + SNOWMAN = "☃" + """:obj:`str`: Snowman""" + NAIL_POLISH = "💅" + """:obj:`str`: Nail polish""" + GRINNING_FACE_WITH_ONE_LARGE_AND_ONE_SMALL_EYE = "🤪" + """:obj:`str`: Grinning face with one large and one small eye""" + MOYAI = "🗿" + """:obj:`str`: Moyai""" + SQUARED_COOL = "🆒" + """:obj:`str`: Squared cool""" + HEART_WITH_ARROW = "💘" + """:obj:`str`: Heart with arrow""" + HEAR_NO_EVIL_MONKEY = "🙉" + """:obj:`str`: Hear-no-evil monkey""" + UNICORN_FACE = "🦄" + """:obj:`str`: Unicorn face""" + FACE_THROWING_A_KISS = "😘" + """:obj:`str`: Face throwing a kiss""" + PILL = "💊" + """:obj:`str`: Pill""" + SPEAK_NO_EVIL_MONKEY = "🙊" + """:obj:`str`: Speak-no-evil monkey""" + SMILING_FACE_WITH_SUNGLASSES = "😎" + """:obj:`str`: Smiling face with sunglasses""" + ALIEN_MONSTER = "👾" + """:obj:`str`: Alien monster""" + MAN_SHRUGGING = "🤷‍♂️" + """:obj:`str`: Man Shrugging""" + SHRUG = "🤷" + """:obj:`str`: Shrug""" + WOMAN_SHRUGGING = "🤷‍♀️" + """:obj:`str`: Woman Shrugging""" + POUTING_FACE = "😡" + """:obj:`str`: Pouting face""" diff --git a/telegram/ext/__init__.py b/telegram/ext/__init__.py index 95826a754bc..00c132d8df4 100644 --- a/telegram/ext/__init__.py +++ b/telegram/ext/__init__.py @@ -30,6 +30,7 @@ "CallbackContext", "CallbackDataCache", "CallbackQueryHandler", + "ChatBoostHandler", "ChatJoinRequestHandler", "ChatMemberHandler", "ChosenInlineResultHandler", @@ -44,6 +45,7 @@ "Job", "JobQueue", "MessageHandler", + "MessageReactionHandler", "PersistenceInput", "PicklePersistence", "PollAnswerHandler", @@ -74,6 +76,7 @@ from ._extbot import ExtBot from ._handlers.basehandler import BaseHandler from ._handlers.callbackqueryhandler import CallbackQueryHandler +from ._handlers.chatboosthandler import ChatBoostHandler from ._handlers.chatjoinrequesthandler import ChatJoinRequestHandler from ._handlers.chatmemberhandler import ChatMemberHandler from ._handlers.choseninlineresulthandler import ChosenInlineResultHandler @@ -81,6 +84,7 @@ from ._handlers.conversationhandler import ConversationHandler from ._handlers.inlinequeryhandler import InlineQueryHandler from ._handlers.messagehandler import MessageHandler +from ._handlers.messagereactionhandler import MessageReactionHandler from ._handlers.pollanswerhandler import PollAnswerHandler from ._handlers.pollhandler import PollHandler from ._handlers.precheckoutqueryhandler import PreCheckoutQueryHandler diff --git a/telegram/ext/_application.py b/telegram/ext/_application.py index 134d80fa638..2925404a392 100644 --- a/telegram/ext/_application.py +++ b/telegram/ext/_application.py @@ -1750,7 +1750,7 @@ async def process_error( update: Optional[object], error: Exception, job: Optional["Job[CCT]"] = None, - coroutine: _ErrorCoroType[RT] = None, + coroutine: Optional[_ErrorCoroType[RT]] = None, ) -> bool: """Processes an error by passing it to all error handlers registered with :meth:`add_error_handler`. If one of the error handlers raises diff --git a/telegram/ext/_callbackdatacache.py b/telegram/ext/_callbackdatacache.py index ca66ceebf02..5e5b28ac86d 100644 --- a/telegram/ext/_callbackdatacache.py +++ b/telegram/ext/_callbackdatacache.py @@ -395,14 +395,14 @@ def process_callback_query(self, callback_query: CallbackQuery) -> None: # Get the cached callback data for the inline keyboard attached to the # CallbackQuery. - if callback_query.message: + if isinstance(callback_query.message, Message): self.__process_message(callback_query.message) - for message in ( + for maybe_message in ( callback_query.message.pinned_message, callback_query.message.reply_to_message, ): - if message: - self.__process_message(message) + if isinstance(maybe_message, Message): + self.__process_message(maybe_message) def drop_data(self, callback_query: CallbackQuery) -> None: """Deletes the data for the specified callback query. diff --git a/telegram/ext/_defaults.py b/telegram/ext/_defaults.py index 26ffed5eec3..9b225ee8396 100644 --- a/telegram/ext/_defaults.py +++ b/telegram/ext/_defaults.py @@ -20,7 +20,11 @@ import datetime from typing import Any, Dict, NoReturn, Optional, final +from telegram import LinkPreviewOptions from telegram._utils.datetime import UTC +from telegram._utils.types import ODVInput +from telegram._utils.warnings import warn +from telegram.warnings import PTBDeprecationWarning @final @@ -38,11 +42,19 @@ class Defaults: parse_mode (:obj:`str`, optional): |parse_mode| disable_notification (:obj:`bool`, optional): |disable_notification| disable_web_page_preview (:obj:`bool`, optional): Disables link previews for links in this - message. - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| - quote (:obj:`bool`, optional): If set to :obj:`True`, the reply is sent as an actual reply - to the message. If ``reply_to_message_id`` is passed, this parameter will - be ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + message. Mutually exclusive with :paramref:`link_preview_options`. + + .. deprecated:: NEXT.VERSION + Use :paramref:`link_preview_options` instead. This parameter will be removed in + future versions. + + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply|. + Will be used for :attr:`telegram.ReplyParameters.allow_sending_without_reply`. + quote (:obj:`bool`, optional): |reply_quote| + + .. deprecated:: NEXT.VERSION + Use :paramref:`do_quote` instead. This parameter will be removed in future + versions. tzinfo (:class:`datetime.tzinfo`, optional): A timezone to be used for all date(time) inputs appearing throughout PTB, i.e. if a timezone naive date(time) object is passed somewhere, it will be assumed to be in :paramref:`tzinfo`. If the @@ -56,6 +68,52 @@ class Defaults: protect_content (:obj:`bool`, optional): |protect_content| .. versionadded:: 20.0 + link_preview_options (:class:`telegram.LinkPreviewOptions`, optional): + Link preview generation options for all outgoing messages. Mutually exclusive with + :paramref:`disable_web_page_preview`. + This object is used for the corresponding parameter of + :meth:`telegram.Bot.send_message`, :meth:`telegram.Bot.edit_message_text`, + and :class:`telegram.InputTextMessageContent` if not specified. If a value is specified + for the corresponding parameter, only those parameters of + :class:`telegram.LinkPreviewOptions` will be overridden that are not + explicitly set. + + Example: + + .. code-block:: python + + from telegram import LinkPreviewOptions + from telegram.ext import Defaults, ExtBot + + defaults = Defaults( + link_preview_options=LinkPreviewOptions(show_above_text=True) + ) + chat_id = 123 + + async def main(): + async with ExtBot("Token", defaults=defaults) as bot: + # The link preview will be shown above the text. + await bot.send_message(chat_id, "https://python-telegram-bot.org") + + # The link preview will be shown below the text. + await bot.send_message( + chat_id, + "https://python-telegram-bot.org", + link_preview_options=LinkPreviewOptions(show_above_text=False) + ) + + # The link preview will be shown above the text, but the preview will + # show Telegram. + await bot.send_message( + chat_id, + "https://python-telegram-bot.org", + link_preview_options=LinkPreviewOptions(url="https://telegram.org") + ) + + .. versionadded:: NEXT.VERSION + do_quote(:obj:`bool`, optional): |reply_quote| + + .. versionadded:: NEXT.VERSION """ __slots__ = ( @@ -63,10 +121,10 @@ class Defaults: "_api_defaults", "_block", "_disable_notification", - "_disable_web_page_preview", + "_do_quote", + "_link_preview_options", "_parse_mode", "_protect_content", - "_quote", "_tzinfo", ) @@ -80,25 +138,54 @@ def __init__( block: bool = True, allow_sending_without_reply: Optional[bool] = None, protect_content: Optional[bool] = None, + link_preview_options: Optional["LinkPreviewOptions"] = None, + do_quote: Optional[bool] = None, ): self._parse_mode: Optional[str] = parse_mode self._disable_notification: Optional[bool] = disable_notification - self._disable_web_page_preview: Optional[bool] = disable_web_page_preview self._allow_sending_without_reply: Optional[bool] = allow_sending_without_reply - self._quote: Optional[bool] = quote self._tzinfo: datetime.tzinfo = tzinfo self._block: bool = block self._protect_content: Optional[bool] = protect_content + if disable_web_page_preview is not None and link_preview_options is not None: + raise ValueError( + "`disable_web_page_preview` and `link_preview_options` are mutually exclusive." + ) + if quote is not None and do_quote is not None: + raise ValueError("`quote` and `do_quote` are mutually exclusive") + if disable_web_page_preview is not None: + warn( + "`Defaults.disable_web_page_preview` is deprecated. Use " + "`Defaults.link_preview_options` instead.", + category=PTBDeprecationWarning, + stacklevel=2, + ) + self._link_preview_options: Optional[LinkPreviewOptions] = LinkPreviewOptions( + is_disabled=disable_web_page_preview + ) + else: + self._link_preview_options = link_preview_options + + if quote is not None: + warn( + "`Defaults.quote` is deprecated. Use `Defaults.do_quote` instead.", + category=PTBDeprecationWarning, + stacklevel=2, + ) + self._do_quote: Optional[bool] = quote + else: + self._do_quote = do_quote # Gather all defaults that actually have a default value self._api_defaults = {} for kwarg in ( "parse_mode", "explanation_parse_mode", "disable_notification", - "disable_web_page_preview", "allow_sending_without_reply", "protect_content", + "link_preview_options", + "do_quote", ): value = getattr(self, kwarg) if value is not None: @@ -115,9 +202,9 @@ def __hash__(self) -> int: ( self._parse_mode, self._disable_notification, - self._disable_web_page_preview, + self.disable_web_page_preview, self._allow_sending_without_reply, - self._quote, + self.quote, self._tzinfo, self._block, self._protect_content, @@ -164,6 +251,19 @@ def explanation_parse_mode(self, value: object) -> NoReturn: "You can not assign a new value to explanation_parse_mode after initialization." ) + @property + def quote_parse_mode(self) -> Optional[str]: + """:obj:`str`: Optional. Alias for :attr:`parse_mode`, used for + the corresponding parameter of :meth:`telegram.ReplyParameters`. + """ + return self._parse_mode + + @quote_parse_mode.setter + def quote_parse_mode(self, value: object) -> NoReturn: + raise AttributeError( + "You can not assign a new value to quote_parse_mode after initialization." + ) + @property def disable_notification(self) -> Optional[bool]: """:obj:`bool`: Optional. Sends the message silently. Users will @@ -178,11 +278,15 @@ def disable_notification(self, value: object) -> NoReturn: ) @property - def disable_web_page_preview(self) -> Optional[bool]: - """:obj:`bool`: Optional. Disables link previews for links in this - message. + def disable_web_page_preview(self) -> ODVInput[bool]: + """:obj:`bool`: Optional. Disables link previews for links in all outgoing + messages. + + .. deprecated:: NEXT.VERSION + Use :attr:`link_preview_options` instead. This attribute will be removed in future + versions. """ - return self._disable_web_page_preview + return self._link_preview_options.is_disabled if self._link_preview_options else None @disable_web_page_preview.setter def disable_web_page_preview(self, value: object) -> NoReturn: @@ -205,11 +309,13 @@ def allow_sending_without_reply(self, value: object) -> NoReturn: @property def quote(self) -> Optional[bool]: - """:obj:`bool`: Optional. If set to :obj:`True`, the reply is sent as an actual reply - to the message. If ``reply_to_message_id`` is passed, this parameter will - be ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + """:obj:`bool`: Optional. |reply_quote| + + .. deprecated:: NEXT.VERSION + Use :attr:`do_quote` instead. This attribute will be removed in future + versions. """ - return self._quote + return self._do_quote if self._do_quote is not None else None @quote.setter def quote(self, value: object) -> NoReturn: @@ -252,3 +358,20 @@ def protect_content(self, value: object) -> NoReturn: raise AttributeError( "You can't assign a new value to protect_content after initialization." ) + + @property + def link_preview_options(self) -> Optional["LinkPreviewOptions"]: + """:class:`telegram.LinkPreviewOptions`: Optional. Link preview generation options for all + outgoing messages. + + .. versionadded:: NEXT.VERSION + """ + return self._link_preview_options + + @property + def do_quote(self) -> Optional[bool]: + """:obj:`bool`: Optional. |reply_quote| + + .. versionadded:: NEXT.VERSION + """ + return self._do_quote diff --git a/telegram/ext/_extbot.py b/telegram/ext/_extbot.py index ff49bee4c78..f03b623da7d 100644 --- a/telegram/ext/_extbot.py +++ b/telegram/ext/_extbot.py @@ -63,6 +63,7 @@ InlineKeyboardMarkup, InlineQueryResultsButton, InputMedia, + LinkPreviewOptions, Location, MaskPosition, MenuButton, @@ -70,12 +71,15 @@ MessageId, PhotoSize, Poll, + ReactionType, + ReplyParameters, SentWebAppMessage, Sticker, StickerSet, TelegramObject, Update, User, + UserChatBoosts, UserProfilePhotos, Venue, Video, @@ -380,6 +384,31 @@ def rate_limiter(self) -> Optional["BaseRateLimiter[RLARGS]"]: # This is a property because the rate limiter shouldn't be changed at runtime return self._rate_limiter + def _merge_lpo_defaults( + self, lpo: ODVInput[LinkPreviewOptions] + ) -> Optional[LinkPreviewOptions]: + # This is a standalone method because both _insert_defaults and + # _insert_defaults_for_ilq_results need this logic + # + # If Defaults.LPO is set, and LPO is passed in the bot method we should fuse + # them, giving precedence to passed values. + # Defaults.LPO(True, "google.com", True) & LPO=LPO(True, ..., False) -> + # LPO(True, "google.com", False) + if self.defaults is None or (defaults_lpo := self.defaults.link_preview_options) is None: + return DefaultValue.get_value(lpo) + return LinkPreviewOptions( + **{ + attr: ( + getattr(defaults_lpo, attr) + # only use the default value + # if the value was explicitly passed to the LPO object + if isinstance(orig_attr := getattr(lpo, attr), DefaultValue) + else orig_attr + ) + for attr in defaults_lpo.__slots__ + } + ) + def _insert_defaults(self, data: Dict[str, object]) -> None: """Inserts the defaults values for optional kwargs for which tg.ext.Defaults provides convenience functionality, i.e. the kwargs with a tg.utils.helpers.DefaultValue default @@ -389,32 +418,33 @@ def _insert_defaults(self, data: Dict[str, object]) -> None: This can only work, if all kwargs that may have defaults are passed in data! """ + if self.defaults is None: + # If we have no defaults to insert, the behavior is the same as in `tg.Bot` + super()._insert_defaults(data) + return + # if we have Defaults, we # 1) replace all DefaultValue instances with the relevant Defaults value. If there is none, # we fall back to the default value of the bot method # 2) convert all datetime.datetime objects to timestamps wrt the correct default timezone # 3) set the correct parse_mode for all InputMedia objects + # 4) handle the LinkPreviewOptions case (see below) + # 5) handle the ReplyParameters case (see below) for key, val in data.items(): # 1) if isinstance(val, DefaultValue): - data[key] = ( - self.defaults.api_defaults.get(key, val.value) - if self.defaults - else DefaultValue.get_value(val) - ) + data[key] = self.defaults.api_defaults.get(key, val.value) # 2) elif isinstance(val, datetime): - data[key] = to_timestamp( - val, tzinfo=self.defaults.tzinfo if self.defaults else None - ) + data[key] = to_timestamp(val, tzinfo=self.defaults.tzinfo) # 3) elif isinstance(val, InputMedia) and val.parse_mode is DEFAULT_NONE: # Copy object as not to edit it in-place copied_val = copy(val) with copied_val._unfrozen(): - copied_val.parse_mode = self.defaults.parse_mode if self.defaults else None + copied_val.parse_mode = self.defaults.parse_mode data[key] = copied_val elif key == "media" and isinstance(val, Sequence): # Copy objects as not to edit them in-place @@ -422,10 +452,35 @@ def _insert_defaults(self, data: Dict[str, object]) -> None: for media in copy_list: if media.parse_mode is DEFAULT_NONE: with media._unfrozen(): - media.parse_mode = self.defaults.parse_mode if self.defaults else None + media.parse_mode = self.defaults.parse_mode data[key] = copy_list + # 4) LinkPreviewOptions: + elif isinstance(val, LinkPreviewOptions): + data[key] = self._merge_lpo_defaults(val) + + # 5) + # Similar to LinkPreviewOptions, but only two of the arguments of RPs have a default + elif isinstance(val, ReplyParameters) and ( + (defaults_aswr := self.defaults.allow_sending_without_reply) is not None + or self.defaults.quote_parse_mode is not None + ): + new_value = copy(val) + with new_value._unfrozen(): + new_value.allow_sending_without_reply = ( + defaults_aswr + if isinstance(val.allow_sending_without_reply, DefaultValue) + else val.allow_sending_without_reply + ) + new_value.quote_parse_mode = ( + self.defaults.quote_parse_mode + if isinstance(val.quote_parse_mode, DefaultValue) + else val.quote_parse_mode + ) + + data[key] = new_value + def _replace_keyboard(self, reply_markup: Optional[ReplyMarkup]) -> Optional[ReplyMarkup]: # If the reply_markup is an inline keyboard and we allow arbitrary callback data, let the # CallbackDataCache build a new keyboard with the data replaced. Otherwise return the input @@ -479,10 +534,10 @@ def _insert_callback_data(self, obj: HandledTypes) -> HandledTypes: if obj.reply_to_message: # reply_to_message can't contain further reply_to_messages, so no need to check self.callback_data_cache.process_message(obj.reply_to_message) - if obj.reply_to_message.pinned_message: + if isinstance(obj.reply_to_message.pinned_message, Message): # pinned messages can't contain reply_to_message, no need to check self.callback_data_cache.process_message(obj.reply_to_message.pinned_message) - if obj.pinned_message: + if isinstance(obj.pinned_message, Message): # pinned messages can't contain reply_to_message, no need to check self.callback_data_cache.process_message(obj.pinned_message) @@ -508,7 +563,8 @@ async def _send_message( caption: Optional[str] = None, parse_mode: ODVInput[str] = DEFAULT_NONE, caption_entities: Optional[Sequence["MessageEntity"]] = None, - disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, + link_preview_options: ODVInput["LinkPreviewOptions"] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -530,7 +586,8 @@ async def _send_message( caption=caption, parse_mode=parse_mode, caption_entities=caption_entities, - disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -613,13 +670,17 @@ def _insert_defaults_for_ilq_results(self, res: "InlineQueryResult") -> "InlineQ `obj`. Overriding this to call insert the actual desired default values. """ + if self.defaults is None: + # If we have no defaults to insert, the behavior is the same as in `tg.Bot` + return super()._insert_defaults_for_ilq_results(res) + # Copy the objects that need modification to avoid modifying the original object copied = False if hasattr(res, "parse_mode") and res.parse_mode is DEFAULT_NONE: res = copy(res) with res._unfrozen(): copied = True - res.parse_mode = self.defaults.parse_mode if self.defaults else None + res.parse_mode = self.defaults.parse_mode if hasattr(res, "input_message_content") and res.input_message_content: if ( hasattr(res.input_message_content, "parse_mode") @@ -629,19 +690,20 @@ def _insert_defaults_for_ilq_results(self, res: "InlineQueryResult") -> "InlineQ res = copy(res) copied = True with res.input_message_content._unfrozen(): - res.input_message_content.parse_mode = ( - self.defaults.parse_mode if self.defaults else None - ) - if ( - hasattr(res.input_message_content, "disable_web_page_preview") - and res.input_message_content.disable_web_page_preview is DEFAULT_NONE - ): + res.input_message_content.parse_mode = self.defaults.parse_mode + if hasattr(res.input_message_content, "link_preview_options"): if not copied: res = copy(res) with res.input_message_content._unfrozen(): - res.input_message_content.disable_web_page_preview = ( - self.defaults.disable_web_page_preview if self.defaults else None - ) + if res.input_message_content.link_preview_options is DEFAULT_NONE: + res.input_message_content.link_preview_options = ( + self.defaults.link_preview_options + ) + else: + # merge the existing options with the defaults + res.input_message_content.link_preview_options = self._merge_lpo_defaults( + res.input_message_content.link_preview_options + ) return res @@ -706,6 +768,7 @@ async def copy_message( reply_markup: Optional[ReplyMarkup] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -728,6 +791,40 @@ async def copy_message( reply_markup=self._replace_keyboard(reply_markup), protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, + 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 copy_messages( + self, + chat_id: Union[int, str], + from_chat_id: Union[str, int], + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: Optional[int] = None, + remove_caption: Optional[bool] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = None, + ) -> Tuple["MessageId", ...]: + # We override this method to call self._replace_keyboard + return await super().copy_messages( + chat_id=chat_id, + from_chat_id=from_chat_id, + message_ids=message_ids, + disable_notification=disable_notification, + protect_content=protect_content, + message_thread_id=message_thread_id, + remove_caption=remove_caption, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -1209,6 +1306,28 @@ async def delete_message( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def delete_messages( + self, + chat_id: Union[str, int], + message_ids: Sequence[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: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = None, + ) -> bool: + return await super().delete_messages( + chat_id=chat_id, + message_ids=message_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 delete_my_commands( self, scope: Optional[BotCommandScope] = None, @@ -1483,6 +1602,7 @@ async def edit_message_text( disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, reply_markup: Optional["InlineKeyboardMarkup"] = None, entities: Optional[Sequence["MessageEntity"]] = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1505,6 +1625,7 @@ async def edit_message_text( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + link_preview_options=link_preview_options, ) async def export_chat_invite_link( @@ -1557,6 +1678,36 @@ async def forward_message( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def forward_messages( + self, + chat_id: Union[int, str], + from_chat_id: Union[str, int], + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: Optional[int] = 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: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = None, + ) -> Tuple[MessageId, ...]: + return await super().forward_messages( + chat_id=chat_id, + from_chat_id=from_chat_id, + message_ids=message_ids, + disable_notification=disable_notification, + protect_content=protect_content, + message_thread_id=message_thread_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 get_chat_administrators( self, chat_id: Union[str, int], @@ -2197,6 +2348,7 @@ async def send_animation( message_thread_id: Optional[int] = None, has_spoiler: Optional[bool] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2223,6 +2375,7 @@ async def send_animation( message_thread_id=message_thread_id, has_spoiler=has_spoiler, thumbnail=thumbnail, + reply_parameters=reply_parameters, filename=filename, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2248,6 +2401,7 @@ async def send_audio( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2273,6 +2427,7 @@ async def send_audio( protect_content=protect_content, message_thread_id=message_thread_id, thumbnail=thumbnail, + reply_parameters=reply_parameters, filename=filename, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2318,6 +2473,7 @@ async def send_contact( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, contact: Optional[Contact] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2339,6 +2495,7 @@ async def send_contact( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, contact=contact, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2357,6 +2514,7 @@ async def send_dice( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2374,6 +2532,7 @@ async def send_dice( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -2396,6 +2555,7 @@ async def send_document( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2419,6 +2579,7 @@ async def send_document( protect_content=protect_content, message_thread_id=message_thread_id, thumbnail=thumbnail, + reply_parameters=reply_parameters, filename=filename, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2437,6 +2598,7 @@ async def send_game( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2454,6 +2616,7 @@ async def send_game( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -2491,6 +2654,7 @@ async def send_invoice( suggested_tip_amounts: Optional[Sequence[int]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2528,6 +2692,7 @@ async def send_invoice( suggested_tip_amounts=suggested_tip_amounts, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -2550,6 +2715,7 @@ async def send_location( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, location: Optional[Location] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2573,6 +2739,7 @@ async def send_location( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, location=location, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2592,6 +2759,7 @@ async def send_media_group( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2611,6 +2779,7 @@ async def send_media_group( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -2634,6 +2803,8 @@ async def send_message( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, reply_markup: Optional[ReplyMarkup] = None, message_thread_id: Optional[int] = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2654,11 +2825,13 @@ async def send_message( reply_to_message_id=reply_to_message_id, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, + reply_parameters=reply_parameters, 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), + link_preview_options=link_preview_options, ) async def send_photo( @@ -2675,6 +2848,7 @@ async def send_photo( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, has_spoiler: Optional[bool] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2697,6 +2871,7 @@ async def send_photo( protect_content=protect_content, message_thread_id=message_thread_id, has_spoiler=has_spoiler, + reply_parameters=reply_parameters, filename=filename, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2726,6 +2901,7 @@ async def send_poll( explanation_entities: Optional[Sequence["MessageEntity"]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2754,6 +2930,7 @@ async def send_poll( explanation_entities=explanation_entities, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -2772,6 +2949,7 @@ async def send_sticker( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, emoji: Optional[str] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2789,6 +2967,7 @@ async def send_sticker( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -2814,6 +2993,7 @@ async def send_venue( allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, venue: Optional[Venue] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2839,6 +3019,7 @@ async def send_venue( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, venue=venue, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2866,6 +3047,7 @@ async def send_video( message_thread_id: Optional[int] = None, has_spoiler: Optional[bool] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2894,6 +3076,7 @@ async def send_video( has_spoiler=has_spoiler, thumbnail=thumbnail, filename=filename, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -2914,6 +3097,7 @@ async def send_video_note( protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, thumbnail: Optional[FileInput] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2935,6 +3119,7 @@ async def send_video_note( protect_content=protect_content, message_thread_id=message_thread_id, thumbnail=thumbnail, + reply_parameters=reply_parameters, filename=filename, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2957,6 +3142,7 @@ async def send_voice( caption_entities: Optional[Sequence["MessageEntity"]] = None, protect_content: ODVInput[bool] = DEFAULT_NONE, message_thread_id: Optional[int] = None, + reply_parameters: Optional["ReplyParameters"] = None, *, filename: Optional[str] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2979,6 +3165,7 @@ async def send_voice( caption_entities=caption_entities, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, filename=filename, read_timeout=read_timeout, write_timeout=write_timeout, @@ -3759,11 +3946,61 @@ async def set_sticker_mask_position( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def get_user_chat_boosts( + self, + chat_id: Union[str, int], + 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: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = None, + ) -> UserChatBoosts: + return await super().get_user_chat_boosts( + chat_id=chat_id, + 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_message_reaction( + self, + chat_id: Union[str, int], + message_id: int, + reaction: Optional[Union[Sequence[Union[ReactionType, str]], ReactionType, str]] = None, + is_big: Optional[bool] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = None, + ) -> bool: + return await super().set_message_reaction( + chat_id=chat_id, + message_id=message_id, + reaction=reaction, + is_big=is_big, + 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 deleteMessage = delete_message + deleteMessages = delete_messages forwardMessage = forward_message + forwardMessages = forward_messages sendPhoto = send_photo sendAudio = send_audio sendDocument = send_document @@ -3843,6 +4080,7 @@ async def set_sticker_mask_position( deleteMyCommands = delete_my_commands logOut = log_out copyMessage = copy_message + copyMessages = copy_messages getChatMenuButton = get_chat_menu_button setChatMenuButton = set_chat_menu_button getMyDefaultAdministratorRights = get_my_default_administrator_rights @@ -3873,3 +4111,5 @@ async def set_sticker_mask_position( setMyName = set_my_name getMyName = get_my_name unpinAllGeneralForumTopicMessages = unpin_all_general_forum_topic_messages + getUserChatBoosts = get_user_chat_boosts + setMessageReaction = set_message_reaction diff --git a/telegram/ext/_handlers/chatboosthandler.py b/telegram/ext/_handlers/chatboosthandler.py new file mode 100644 index 00000000000..447eef249c5 --- /dev/null +++ b/telegram/ext/_handlers/chatboosthandler.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# 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 the ChatBoostHandler class.""" + +from typing import Final, Optional + +from telegram import Update +from telegram.ext._handlers.basehandler import BaseHandler +from telegram.ext._utils._update_parsing import parse_chat_id, parse_username +from telegram.ext._utils.types import CCT, HandlerCallback + + +class ChatBoostHandler(BaseHandler[Update, CCT]): + """ + Handler class to handle Telegram updates that contain a chat boost. + + Warning: + When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom + attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info. + + .. versionadded:: NEXT.VERSION + + Args: + callback (:term:`coroutine function`): The callback function for this handler. Will be + called when :meth:`check_update` has determined that an update should be processed by + this handler. Callback signature:: + + async def callback(update: Update, context: CallbackContext) + + The return value of the callback is usually ignored except for the special case of + :class:`telegram.ext.ConversationHandler`. + chat_boost_types (:obj:`int`, optional): Pass one of + :attr:`CHAT_BOOST`, :attr:`REMOVED_CHAT_BOOST` or + :attr:`ANY_CHAT_BOOST` to specify if this handler should handle only updates with + :attr:`telegram.Update.chat_boost`, + :attr:`telegram.Update.removed_chat_boost` or both. Defaults to + :attr:`CHAT_BOOST`. + chat_id (:obj:`int` | Collection[:obj:`int`], optional): Filters reactions to allow + only those which happen in the specified chat ID(s). + chat_username (:obj:`str` | Collection[:obj:`str`], optional): Filters reactions to allow + only those which happen in the specified username(s). + block (:obj:`bool`, optional): Determines whether the return value of the callback should + be awaited before processing the next handler in + :meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`. + + .. seealso:: :wiki:`Concurrency` + + Attributes: + callback (:term:`coroutine function`): The callback function for this handler. + chat_boost_types (:obj:`int`): Optional. Specifies if this handler should handle only + updates with :attr:`telegram.Update.chat_boost`, + :attr:`telegram.Update.removed_chat_boost` or both. + block (:obj:`bool`): Determines whether the callback will run in a blocking way. + """ + + __slots__ = ( + "_chat_ids", + "_chat_usernames", + "chat_boost_types", + ) + + CHAT_BOOST: Final[int] = -1 + """ :obj:`int`: Used as a constant to handle only :attr:`telegram.Update.chat_boost`.""" + REMOVED_CHAT_BOOST: Final[int] = 0 + """:obj:`int`: Used as a constant to handle only :attr:`telegram.Update.removed_chat_boost`.""" + ANY_CHAT_BOOST: Final[int] = 1 + """:obj:`int`: Used as a constant to handle both :attr:`telegram.Update.chat_boost` + and :attr:`telegram.Update.removed_chat_boost`.""" + + def __init__( + self, + callback: HandlerCallback[Update, CCT, None], + chat_boost_types: int = CHAT_BOOST, + chat_id: Optional[int] = None, + chat_username: Optional[str] = None, + block: bool = True, + ): + super().__init__(callback, block=block) + self.chat_boost_types: int = chat_boost_types + self._chat_ids = parse_chat_id(chat_id) + self._chat_usernames = parse_username(chat_username) + + def check_update(self, update: object) -> bool: + """Determines whether an update should be passed to this handler's :attr:`callback`. + + Args: + update (:class:`telegram.Update` | :obj:`object`): Incoming update. + + Returns: + :obj:`bool` + + """ + if not isinstance(update, Update): + return False + + if not (update.chat_boost or update.removed_chat_boost): + return False + + if self.chat_boost_types == self.CHAT_BOOST and not update.chat_boost: + return False + + if self.chat_boost_types == self.REMOVED_CHAT_BOOST and not update.removed_chat_boost: + return False + + if not any((self._chat_ids, self._chat_usernames)): + return True + + # Extract chat and user IDs and usernames from the update for comparison + chat_id = chat.id if (chat := update.effective_chat) else None + chat_username = chat.username if chat else None + + return bool(self._chat_ids and (chat_id in self._chat_ids)) or bool( + self._chat_usernames and (chat_username in self._chat_usernames) + ) diff --git a/telegram/ext/_handlers/chatjoinrequesthandler.py b/telegram/ext/_handlers/chatjoinrequesthandler.py index 83894758043..79ebdccc4b9 100644 --- a/telegram/ext/_handlers/chatjoinrequesthandler.py +++ b/telegram/ext/_handlers/chatjoinrequesthandler.py @@ -18,12 +18,13 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the ChatJoinRequestHandler class.""" -from typing import FrozenSet, Optional +from typing import Optional from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE from telegram._utils.types import RT, SCT, DVType from telegram.ext._handlers.basehandler import BaseHandler +from telegram.ext._utils._update_parsing import parse_chat_id, parse_username from telegram.ext._utils.types import CCT, HandlerCallback @@ -88,24 +89,8 @@ def __init__( ): super().__init__(callback, block=block) - self._chat_ids = self._parse_chat_id(chat_id) - self._usernames = self._parse_username(username) - - @staticmethod - def _parse_chat_id(chat_id: Optional[SCT[int]]) -> FrozenSet[int]: - if chat_id is None: - return frozenset() - if isinstance(chat_id, int): - return frozenset({chat_id}) - return frozenset(chat_id) - - @staticmethod - def _parse_username(username: Optional[SCT[str]]) -> FrozenSet[str]: - if username is None: - return frozenset() - if isinstance(username, str): - return frozenset({username[1:] if username.startswith("@") else username}) - return frozenset({usr[1:] if usr.startswith("@") else usr for usr in username}) + self._chat_ids = parse_chat_id(chat_id) + self._usernames = parse_username(username) def check_update(self, update: object) -> bool: """Determines whether an update should be passed to this handler's :attr:`callback`. diff --git a/telegram/ext/_handlers/messagereactionhandler.py b/telegram/ext/_handlers/messagereactionhandler.py new file mode 100644 index 00000000000..fecec3e02da --- /dev/null +++ b/telegram/ext/_handlers/messagereactionhandler.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2023 +# 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 the MessageReactionHandler class.""" + +from typing import Final, Optional + +from telegram import Update +from telegram._utils.defaultvalue import DEFAULT_TRUE +from telegram._utils.types import RT, SCT, DVType +from telegram.ext._handlers.basehandler import BaseHandler +from telegram.ext._utils._update_parsing import parse_chat_id, parse_username +from telegram.ext._utils.types import CCT, HandlerCallback + + +class MessageReactionHandler(BaseHandler[Update, CCT]): + """Handler class to handle Telegram updates that contain a message reaction. + + Note: + The following rules apply to both ``username`` and the ``chat_id`` param groups, + respectively: + + * If none of them are passed, the handler does not filter the update for that specific + attribute. + * If a chat ID **or** a username is passed, the updates will be filtered with that + specific attribute. + * If a chat ID **and** a username are passed, an update containing **any** of them will be + filtered. + * :attr:`telegram.MessageReactionUpdated.actor_chat` is *not* considered for + :paramref:`user_id` and :paramref:`user_username` filtering. + + Warning: + When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom + attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info. + + .. versionadded:: NEXT.VERSION + + Args: + callback (:term:`coroutine function`): The callback function for this handler. Will be + called when :meth:`check_update` has determined that an update should be processed by + this handler. Callback signature:: + + async def callback(update: Update, context: CallbackContext) + + The return value of the callback is usually ignored except for the special case of + :class:`telegram.ext.ConversationHandler`. + message_reaction_types (:obj:`int`, optional): Pass one of + :attr:`MESSAGE_REACTION_UPDATED`, :attr:`MESSAGE_REACTION_COUNT_UPDATED` or + :attr:`MESSAGE_REACTION` to specify if this handler should handle only updates with + :attr:`telegram.Update.message_reaction`, + :attr:`telegram.Update.message_reaction_count` or both. Defaults to + :attr:`MESSAGE_REACTION`. + chat_id (:obj:`int` | Collection[:obj:`int`], optional): Filters reactions to allow + only those which happen in the specified chat ID(s). + chat_username (:obj:`str` | Collection[:obj:`str`], optional): Filters reactions to allow + only those which happen in the specified username(s). + user_id (:obj:`int` | Collection[:obj:`int`], optional): Filters reactions to allow + only those which are set by the specified chat ID(s) (this can be the chat itself in + the case of anonymous users, see the + :paramref:`telegram.MessageReactionUpdated.actor_chat`). + user_username (:obj:`str` | Collection[:obj:`str`], optional): Filters reactions to allow + only those which are set by the specified username(s) (this can be the chat itself in + the case of anonymous users, see the + :paramref:`telegram.MessageReactionUpdated.actor_chat`). + block (:obj:`bool`, optional): Determines whether the return value of the callback should + be awaited before processing the next handler in + :meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`. + + .. seealso:: :wiki:`Concurrency` + + Attributes: + callback (:term:`coroutine function`): The callback function for this handler. + message_reaction_types (:obj:`int`): Optional. Specifies if this handler should handle only + updates with :attr:`telegram.Update.message_reaction`, + :attr:`telegram.Update.message_reaction_count` or both. + block (:obj:`bool`): Determines whether the callback will run in a blocking way. + + """ + + __slots__ = ( + "_chat_ids", + "_chat_usernames", + "_user_ids", + "_user_usernames", + "message_reaction_types", + ) + + MESSAGE_REACTION_UPDATED: Final[int] = -1 + """:obj:`int`: Used as a constant to handle only :attr:`telegram.Update.message_reaction`.""" + MESSAGE_REACTION_COUNT_UPDATED: Final[int] = 0 + """:obj:`int`: Used as a constant to handle only + :attr:`telegram.Update.message_reaction_count`.""" + MESSAGE_REACTION: Final[int] = 1 + """:obj:`int`: Used as a constant to handle both :attr:`telegram.Update.message_reaction` + and :attr:`telegram.Update.message_reaction_count`.""" + + def __init__( + self, + callback: HandlerCallback[Update, CCT, RT], + chat_id: Optional[SCT[int]] = None, + chat_username: Optional[SCT[str]] = None, + user_id: Optional[SCT[int]] = None, + user_username: Optional[SCT[str]] = None, + message_reaction_types: int = MESSAGE_REACTION, + block: DVType[bool] = DEFAULT_TRUE, + ): + super().__init__(callback, block=block) + self.message_reaction_types: int = message_reaction_types + + self._chat_ids = parse_chat_id(chat_id) + self._chat_usernames = parse_username(chat_username) + if (user_id or user_username) and message_reaction_types in ( + self.MESSAGE_REACTION, + self.MESSAGE_REACTION_COUNT_UPDATED, + ): + raise ValueError( + "You can not filter for users and include anonymous reactions. Set " + "`message_reaction_types` to MESSAGE_REACTION_UPDATED." + ) + self._user_ids = parse_chat_id(user_id) + self._user_usernames = parse_username(user_username) + + def check_update(self, update: object) -> bool: + """Determines whether an update should be passed to this handler's :attr:`callback`. + + Args: + update (:class:`telegram.Update` | :obj:`object`): Incoming update. + + Returns: + :obj:`bool` + + """ + if not isinstance(update, Update): + return False + + if not (update.message_reaction or update.message_reaction_count): + return False + + if ( + self.message_reaction_types == self.MESSAGE_REACTION_UPDATED + and update.message_reaction_count + ): + return False + + if ( + self.message_reaction_types == self.MESSAGE_REACTION_COUNT_UPDATED + and update.message_reaction + ): + return False + + if not any((self._chat_ids, self._chat_usernames, self._user_ids, self._user_usernames)): + return True + + # Extract chat and user IDs and usernames from the update for comparison + chat_id = chat.id if (chat := update.effective_chat) else None + chat_username = chat.username if chat else None + user_id = user.id if (user := update.effective_user) else None + user_username = user.username if user else None + + return ( + bool(self._chat_ids and (chat_id in self._chat_ids)) + or bool(self._chat_usernames and (chat_username in self._chat_usernames)) + or bool(self._user_ids and (user_id in self._user_ids)) + or bool(self._user_usernames and (user_username in self._user_usernames)) + ) diff --git a/telegram/ext/_utils/_update_parsing.py b/telegram/ext/_utils/_update_parsing.py new file mode 100644 index 00000000000..4a8430bab12 --- /dev/null +++ b/telegram/ext/_utils/_update_parsing.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2023 +# 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 helper functions related to parsing updates and their contents. + +.. versionadded:: NEXT.VERSION + +Warning: + Contents of this module are intended to be used internally by the library and *not* by the + user. Changes to this module are not considered breaking changes and may not be documented in + the changelog. +""" +from typing import FrozenSet, Optional + +from telegram._utils.types import SCT + + +def parse_chat_id(chat_id: Optional[SCT[int]]) -> FrozenSet[int]: + """Accepts a chat id or collection of chat ids and returns a frozenset of chat ids.""" + if chat_id is None: + return frozenset() + if isinstance(chat_id, int): + return frozenset({chat_id}) + return frozenset(chat_id) + + +def parse_username(username: Optional[SCT[str]]) -> FrozenSet[str]: + """Accepts a username or collection of usernames and returns a frozenset of usernames. + Strips the leading ``@`` if present. + """ + if username is None: + return frozenset() + if isinstance(username, str): + return frozenset({username[1:] if username.startswith("@") else username}) + return frozenset({usr[1:] if usr.startswith("@") else usr for usr in username}) diff --git a/telegram/ext/filters.py b/telegram/ext/filters.py index 43e0beb2de3..9fa805fdb59 100644 --- a/telegram/ext/filters.py +++ b/telegram/ext/filters.py @@ -47,6 +47,8 @@ "CONTACT", "FORWARDED", "GAME", + "GIVEAWAY", + "GIVEAWAY_WINNERS", "HAS_MEDIA_SPOILER", "HAS_PROTECTED_CONTENT", "INVOICE", @@ -114,10 +116,18 @@ ) from telegram import Chat as TGChat -from telegram import Message, MessageEntity, Update +from telegram import ( + Message, + MessageEntity, + MessageOriginChannel, + MessageOriginChat, + MessageOriginUser, + Update, +) from telegram import User as TGUser from telegram._utils.types import SCT from telegram.constants import DiceEmoji as DiceEmojiEnum +from telegram.ext._utils._update_parsing import parse_chat_id, parse_username from telegram.ext._utils.types import FilterDataDict @@ -673,29 +683,13 @@ def __init__( @abstractmethod def _get_chat_or_user(self, message: Message) -> Union[TGChat, TGUser, None]: ... - @staticmethod - def _parse_chat_id(chat_id: Optional[SCT[int]]) -> Set[int]: - if chat_id is None: - return set() - if isinstance(chat_id, int): - return {chat_id} - return set(chat_id) - - @staticmethod - def _parse_username(username: Optional[SCT[str]]) -> Set[str]: - if username is None: - return set() - if isinstance(username, str): - return {username[1:] if username.startswith("@") else username} - return {chat[1:] if chat.startswith("@") else chat for chat in username} - def _set_chat_ids(self, chat_id: Optional[SCT[int]]) -> None: if chat_id and self._usernames: raise RuntimeError( f"Can't set {self._chat_id_name} in conjunction with (already set) " f"{self._username_name}s." ) - self._chat_ids = self._parse_chat_id(chat_id) + self._chat_ids = set(parse_chat_id(chat_id)) def _set_usernames(self, username: Optional[SCT[str]]) -> None: if username and self._chat_ids: @@ -703,7 +697,7 @@ def _set_usernames(self, username: Optional[SCT[str]]) -> None: f"Can't set {self._username_name} in conjunction with (already set) " f"{self._chat_id_name}s." ) - self._usernames = self._parse_username(username) + self._usernames = set(parse_username(username)) @property def chat_ids(self) -> FrozenSet[int]: @@ -747,7 +741,7 @@ def add_usernames(self, username: SCT[str]) -> None: f"{self._chat_id_name}s." ) - parsed_username = self._parse_username(username) + parsed_username = set(parse_username(username)) self._usernames |= parsed_username def _add_chat_ids(self, chat_id: SCT[int]) -> None: @@ -757,7 +751,7 @@ def _add_chat_ids(self, chat_id: SCT[int]) -> None: f"{self._username_name}s." ) - parsed_chat_id = self._parse_chat_id(chat_id) + parsed_chat_id = set(parse_chat_id(chat_id)) self._chat_ids |= parsed_chat_id @@ -775,7 +769,7 @@ def remove_usernames(self, username: SCT[str]) -> None: f"{self._chat_id_name}s." ) - parsed_username = self._parse_username(username) + parsed_username = set(parse_username(username)) self._usernames -= parsed_username def _remove_chat_ids(self, chat_id: SCT[int]) -> None: @@ -784,7 +778,7 @@ def _remove_chat_ids(self, chat_id: SCT[int]) -> None: f"Can't set {self._chat_id_name} in conjunction with (already set) " f"{self._username_name}s." ) - parsed_chat_id = self._parse_chat_id(chat_id) + parsed_chat_id = set(parse_chat_id(chat_id)) self._chat_ids -= parsed_chat_id def filter(self, message: Message) -> bool: @@ -1362,28 +1356,40 @@ class _Forwarded(MessageFilter): __slots__ = () def filter(self, message: Message) -> bool: - return bool(message.forward_date) + return bool(message.forward_origin) FORWARDED = _Forwarded(name="filters.FORWARDED") -"""Messages that contain :attr:`telegram.Message.forward_date`.""" +"""Messages that contain :attr:`telegram.Message.forward_origin`. + +.. versionchanged:: NEXT.VERSION + Now based on :attr:`telegram.Message.forward_origin` instead of + :attr:`telegram.Message.forward_date`. +""" class ForwardedFrom(_ChatUserBaseFilter): """Filters messages to allow only those which are forwarded from the specified chat ID(s) - or username(s) based on :attr:`telegram.Message.forward_from` and - :attr:`telegram.Message.forward_from_chat`. + or username(s) based on :attr:`telegram.Message.forward_origin` and in particular + + * :attr:`telegram.MessageOriginUser.sender_user` + * :attr:`telegram.MessageOriginChat.sender_chat` + * :attr:`telegram.MessageOriginChannel.chat` .. versionadded:: 13.5 + .. versionchanged:: NEXT.VERSION + Was previously based on :attr:`telegram.Message.forward_from` and + :attr:`telegram.Message.forward_from_chat`. + Examples: ``MessageHandler(filters.ForwardedFrom(chat_id=1234), callback_method)`` Note: When a user has disallowed adding a link to their account while forwarding their - messages, this filter will *not* work since both - :attr:`telegram.Message.forward_from` and - :attr:`telegram.Message.forward_from_chat` are :obj:`None`. However, this behaviour + messages, this filter will *not* work since + :attr:`telegram.Message.forward_origin` will be of type + :class:`telegram.MessageOriginHiddenUser`. However, this behaviour is undocumented and might be changed by Telegram. Warning: @@ -1414,7 +1420,17 @@ class ForwardedFrom(_ChatUserBaseFilter): __slots__ = () def _get_chat_or_user(self, message: Message) -> Union[TGUser, TGChat, None]: - return message.forward_from or message.forward_from_chat + if (forward_origin := message.forward_origin) is None: + return None + + if isinstance(forward_origin, MessageOriginUser): + return forward_origin.sender_user + if isinstance(forward_origin, MessageOriginChat): + return forward_origin.sender_chat + if isinstance(forward_origin, MessageOriginChannel): + return forward_origin.chat + + return None def add_chat_ids(self, chat_id: SCT[int]) -> None: """ @@ -1448,6 +1464,28 @@ def filter(self, message: Message) -> bool: """Messages that contain :attr:`telegram.Message.game`.""" +class _Giveaway(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.giveaway) + + +GIVEAWAY = _Giveaway(name="filters.GIVEAWAY") +"""Messages that contain :attr:`telegram.Message.giveaway`.""" + + +class _GiveawayWinners(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.giveaway_winners) + + +GIVEAWAY_WINNERS = _GiveawayWinners(name="filters.GIVEAWAY_WINNERS") +"""Messages that contain :attr:`telegram.Message.giveaway_winners`.""" + + class _HasMediaSpoiler(MessageFilter): __slots__ = () @@ -1846,31 +1884,35 @@ class _All(UpdateFilter): def filter(self, update: Update) -> bool: return bool( - StatusUpdate.NEW_CHAT_MEMBERS.check_update(update) - or StatusUpdate.LEFT_CHAT_MEMBER.check_update(update) - or StatusUpdate.NEW_CHAT_TITLE.check_update(update) - or StatusUpdate.NEW_CHAT_PHOTO.check_update(update) + # keep this alphabetically sorted for easier maintenance + StatusUpdate.CHAT_CREATED.check_update(update) + or StatusUpdate.CHAT_SHARED.check_update(update) + or StatusUpdate.CONNECTED_WEBSITE.check_update(update) or StatusUpdate.DELETE_CHAT_PHOTO.check_update(update) - or StatusUpdate.CHAT_CREATED.check_update(update) + or StatusUpdate.FORUM_TOPIC_CLOSED.check_update(update) + or StatusUpdate.FORUM_TOPIC_CREATED.check_update(update) + or StatusUpdate.FORUM_TOPIC_EDITED.check_update(update) + or StatusUpdate.FORUM_TOPIC_REOPENED.check_update(update) + or StatusUpdate.GENERAL_FORUM_TOPIC_HIDDEN.check_update(update) + or StatusUpdate.GENERAL_FORUM_TOPIC_UNHIDDEN.check_update(update) + or StatusUpdate.GIVEAWAY_COMPLETED.check_update(update) + or StatusUpdate.GIVEAWAY_CREATED.check_update(update) + or StatusUpdate.LEFT_CHAT_MEMBER.check_update(update) or StatusUpdate.MESSAGE_AUTO_DELETE_TIMER_CHANGED.check_update(update) or StatusUpdate.MIGRATE.check_update(update) + or StatusUpdate.NEW_CHAT_MEMBERS.check_update(update) + or StatusUpdate.NEW_CHAT_PHOTO.check_update(update) + or StatusUpdate.NEW_CHAT_TITLE.check_update(update) or StatusUpdate.PINNED_MESSAGE.check_update(update) - or StatusUpdate.CONNECTED_WEBSITE.check_update(update) or StatusUpdate.PROXIMITY_ALERT_TRIGGERED.check_update(update) - or StatusUpdate.VIDEO_CHAT_SCHEDULED.check_update(update) - or StatusUpdate.VIDEO_CHAT_STARTED.check_update(update) + or StatusUpdate.USERS_SHARED.check_update(update) + or StatusUpdate.USER_SHARED.check_update(update) or StatusUpdate.VIDEO_CHAT_ENDED.check_update(update) or StatusUpdate.VIDEO_CHAT_PARTICIPANTS_INVITED.check_update(update) + or StatusUpdate.VIDEO_CHAT_SCHEDULED.check_update(update) + or StatusUpdate.VIDEO_CHAT_STARTED.check_update(update) or StatusUpdate.WEB_APP_DATA.check_update(update) - or StatusUpdate.FORUM_TOPIC_CREATED.check_update(update) - or StatusUpdate.FORUM_TOPIC_CLOSED.check_update(update) - or StatusUpdate.FORUM_TOPIC_REOPENED.check_update(update) - or StatusUpdate.FORUM_TOPIC_EDITED.check_update(update) - or StatusUpdate.GENERAL_FORUM_TOPIC_HIDDEN.check_update(update) - or StatusUpdate.GENERAL_FORUM_TOPIC_UNHIDDEN.check_update(update) or StatusUpdate.WRITE_ACCESS_ALLOWED.check_update(update) - or StatusUpdate.USER_SHARED.check_update(update) - or StatusUpdate.CHAT_SHARED.check_update(update) ) ALL = _All(name="filters.StatusUpdate.ALL") @@ -1997,6 +2039,29 @@ def filter(self, message: Message) -> bool: .. versionadded:: 20.0 """ + class _GiveawayCreated(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.giveaway_created) + + GIVEAWAY_CREATED = _GiveawayCreated(name="filters.StatusUpdate.GIVEAWAY_CREATED") + """Messages that contain :attr:`telegram.Message.giveaway_created`. + + .. versionadded:: NEXT.VERSION + """ + + class _GiveawayCompleted(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.giveaway_completed) + + GIVEAWAY_COMPLETED = _GiveawayCompleted(name="filters.StatusUpdate.GIVEAWAY_COMPLETED") + """Messages that contain :attr:`telegram.Message.giveaway_completed`. + .. versionadded:: NEXT.VERSION + """ + class _LeftChatMember(MessageFilter): __slots__ = () @@ -2086,7 +2151,25 @@ def filter(self, message: Message) -> bool: USER_SHARED = _UserShared(name="filters.StatusUpdate.USER_SHARED") """Messages that contain :attr:`telegram.Message.user_shared`. + Warning: + This will only catch the legacy :attr:`telegram.Message.user_shared` attribute, not the + new :attr:`telegram.Message.users_shared` attribute! + .. versionadded:: 20.1 + .. deprecated:: NEXT.VERSION + Use :attr:`USERS_SHARED` instead. + """ + + class _UsersShared(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.users_shared) + + USERS_SHARED = _UsersShared(name="filters.StatusUpdate.USERS_SHARED") + """Messages that contain :attr:`telegram.Message.users_shared`. + + .. versionadded:: NEXT.VERSION """ class _VideoChatEnded(MessageFilter): diff --git a/tests/_files/test_animation.py b/tests/_files/test_animation.py index e43c015835a..b35c509d1bb 100644 --- a/tests/_files/test_animation.py +++ b/tests/_files/test_animation.py @@ -22,7 +22,8 @@ import pytest -from telegram import Animation, Bot, InputFile, MessageEntity, PhotoSize, Voice +from telegram import Animation, Bot, InputFile, 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 @@ -31,6 +32,7 @@ check_shortcut_call, check_shortcut_signature, ) +from tests.auxil.build_messages import make_message from tests.auxil.files import data_file from tests.auxil.slots import mro_slots @@ -189,6 +191,33 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(animation.get_bot(), "get_file", make_assertion) assert await animation.get_file() + @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_animation_default_quote_parse_mode( + self, default_bot, chat_id, animation, 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_animation( + chat_id, animation, reply_parameters=ReplyParameters(**kwargs) + ) + class TestAnimationWithRequest(TestAnimationBase): async def test_send_all_args(self, bot, chat_id, animation_file, animation, thumb_file): diff --git a/tests/_files/test_audio.py b/tests/_files/test_audio.py index 3ea394d0bda..9a19ea1524e 100644 --- a/tests/_files/test_audio.py +++ b/tests/_files/test_audio.py @@ -22,8 +22,9 @@ import pytest -from telegram import Audio, Bot, InputFile, MessageEntity, Voice -from telegram.error import TelegramError +from telegram import Audio, Bot, InputFile, MessageEntity, 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.bot_method_checks import ( @@ -31,6 +32,7 @@ check_shortcut_call, check_shortcut_signature, ) +from tests.auxil.build_messages import make_message from tests.auxil.files import data_file from tests.auxil.slots import mro_slots @@ -194,6 +196,31 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(audio._bot, "get_file", make_assertion) assert await audio.get_file() + @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_audio_default_quote_parse_mode( + self, default_bot, chat_id, audio, 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_audio(chat_id, audio, reply_parameters=ReplyParameters(**kwargs)) + class TestAudioWithRequest(TestAudioBase): async def test_send_all_args(self, bot, chat_id, audio_file, thumb_file): @@ -322,3 +349,36 @@ async def test_error_send_empty_file_id(self, bot, chat_id): async def test_error_send_without_required_args(self, bot, chat_id): with pytest.raises(TypeError): await bot.send_audio(chat_id=chat_id) + + @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_audio_default_allow_sending_without_reply( + self, default_bot, chat_id, audio, 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_audio( + chat_id, + audio, + 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_audio( + chat_id, audio, reply_to_message_id=reply_to_message.message_id + ) + assert message.reply_to_message is None + else: + with pytest.raises(BadRequest, match="Message to reply not found"): + await default_bot.send_audio( + chat_id, audio, reply_to_message_id=reply_to_message.message_id + ) diff --git a/tests/_files/test_contact.py b/tests/_files/test_contact.py index 3f44ed250e1..b8fa54da851 100644 --- a/tests/_files/test_contact.py +++ b/tests/_files/test_contact.py @@ -21,9 +21,11 @@ import pytest -from telegram import Contact, Voice +from telegram import Contact, ReplyParameters, Voice +from telegram.constants import ParseMode from telegram.error import BadRequest from telegram.request import RequestData +from tests.auxil.build_messages import make_message from tests.auxil.slots import mro_slots @@ -126,6 +128,33 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): monkeypatch.setattr(bot.request, "post", make_assertion) assert await bot.send_contact(contact=contact, chat_id=chat_id) + @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_contact_default_quote_parse_mode( + self, default_bot, chat_id, contact, 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_contact( + chat_id, contact=contact, reply_parameters=ReplyParameters(**kwargs) + ) + class TestContactWithRequest(TestContactBase): @pytest.mark.parametrize( diff --git a/tests/_files/test_document.py b/tests/_files/test_document.py index 5026bfe69fb..76236ae2eaa 100644 --- a/tests/_files/test_document.py +++ b/tests/_files/test_document.py @@ -22,7 +22,8 @@ import pytest -from telegram import Bot, Document, InputFile, MessageEntity, PhotoSize, Voice +from telegram import Bot, Document, InputFile, 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 @@ -31,6 +32,7 @@ check_shortcut_call, check_shortcut_signature, ) +from tests.auxil.build_messages import make_message from tests.auxil.files import data_file from tests.auxil.slots import mro_slots @@ -151,6 +153,33 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): assert message + @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_document_default_quote_parse_mode( + self, default_bot, chat_id, document, 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_document( + chat_id, document, reply_parameters=ReplyParameters(**kwargs) + ) + @pytest.mark.parametrize("local_mode", [True, False]) async def test_send_document_local_files(self, monkeypatch, bot, chat_id, local_mode): try: diff --git a/tests/_files/test_inputmedia.py b/tests/_files/test_inputmedia.py index 996114414ea..066958681c2 100644 --- a/tests/_files/test_inputmedia.py +++ b/tests/_files/test_inputmedia.py @@ -33,6 +33,7 @@ InputMediaVideo, Message, MessageEntity, + ReplyParameters, ) from telegram.constants import InputMediaType, ParseMode @@ -47,6 +48,8 @@ # noinspection PyUnresolvedReferences from tests.test_forum import emoji_id, real_topic # noqa: F401 +from ..auxil.build_messages import make_message + # noinspection PyUnresolvedReferences from .test_audio import audio, audio_file # noqa: F401 @@ -609,6 +612,33 @@ async def make_assertion( with pytest.raises(Exception, match="Test was successful"): await bot.edit_message_media(chat_id=chat_id, message_id=123, media=input_video) + @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_media_group_default_quote_parse_mode( + self, default_bot, chat_id, media_group, 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_media_group( + chat_id, media_group, reply_parameters=ReplyParameters(**kwargs) + ) + class CustomSequence(Sequence): def __init__(self, items): diff --git a/tests/_files/test_location.py b/tests/_files/test_location.py index 9df749df167..59e881969b8 100644 --- a/tests/_files/test_location.py +++ b/tests/_files/test_location.py @@ -20,9 +20,11 @@ import pytest -from telegram import Location +from telegram import Location, ReplyParameters +from telegram.constants import ParseMode from telegram.error import BadRequest from telegram.request import RequestData +from tests.auxil.build_messages import make_message from tests.auxil.slots import mro_slots @@ -159,6 +161,33 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): monkeypatch.setattr(bot.request, "post", make_assertion) assert await bot.edit_message_live_location(None, None, location=location) + @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_location_default_quote_parse_mode( + self, default_bot, chat_id, location, 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_location( + chat_id, location=location, reply_parameters=ReplyParameters(**kwargs) + ) + class TestLocationWithRequest: @pytest.mark.parametrize( diff --git a/tests/_files/test_photo.py b/tests/_files/test_photo.py index a8044a9d5e7..ec3fbf59a7b 100644 --- a/tests/_files/test_photo.py +++ b/tests/_files/test_photo.py @@ -22,7 +22,8 @@ import pytest -from telegram import Bot, InputFile, MessageEntity, PhotoSize, Sticker +from telegram import Bot, InputFile, MessageEntity, PhotoSize, ReplyParameters, Sticker +from telegram.constants import ParseMode from telegram.error import BadRequest, TelegramError from telegram.helpers import escape_markdown from telegram.request import RequestData @@ -31,6 +32,7 @@ check_shortcut_call, check_shortcut_signature, ) +from tests.auxil.build_messages import make_message from tests.auxil.files import data_file from tests.auxil.networking import expect_bad_request from tests.auxil.slots import mro_slots @@ -209,6 +211,31 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(photo.get_bot(), "get_file", make_assertion) assert await photo.get_file() + @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_photo_default_quote_parse_mode( + self, default_bot, chat_id, 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_photo(chat_id, photo, reply_parameters=ReplyParameters(**kwargs)) + class TestPhotoWithRequest(TestPhotoBase): async def test_send_photo_all_args(self, bot, chat_id, photo_file): diff --git a/tests/_files/test_sticker.py b/tests/_files/test_sticker.py index 2242d3f44eb..c11141acdfb 100644 --- a/tests/_files/test_sticker.py +++ b/tests/_files/test_sticker.py @@ -32,10 +32,11 @@ InputSticker, MaskPosition, PhotoSize, + ReplyParameters, Sticker, StickerSet, ) -from telegram.constants import StickerFormat, StickerType +from telegram.constants import ParseMode, StickerFormat, StickerType from telegram.error import BadRequest, TelegramError from telegram.request import RequestData from tests.auxil.bot_method_checks import ( @@ -43,6 +44,7 @@ check_shortcut_call, check_shortcut_signature, ) +from tests.auxil.build_messages import make_message from tests.auxil.files import data_file from tests.auxil.slots import mro_slots @@ -315,6 +317,33 @@ async def make_assertion(_, data, *args, **kwargs): finally: bot._local_mode = False + @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_sticker_default_quote_parse_mode( + self, default_bot, chat_id, sticker, 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_sticker( + chat_id, sticker, reply_parameters=ReplyParameters(**kwargs) + ) + class TestStickerWithRequest(TestStickerBase): async def test_send_all_args(self, bot, chat_id, sticker_file, sticker): diff --git a/tests/_files/test_venue.py b/tests/_files/test_venue.py index 3a63252800a..2ae53c5a6a7 100644 --- a/tests/_files/test_venue.py +++ b/tests/_files/test_venue.py @@ -20,9 +20,11 @@ import pytest -from telegram import Location, Venue +from telegram import Location, ReplyParameters, Venue +from telegram.constants import ParseMode from telegram.error import BadRequest from telegram.request import RequestData +from tests.auxil.build_messages import make_message from tests.auxil.slots import mro_slots @@ -141,6 +143,33 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): message = await bot.send_venue(chat_id, venue=venue) assert message + @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_venue_default_quote_parse_mode( + self, default_bot, chat_id, venue, 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_venue( + chat_id, venue=venue, reply_parameters=ReplyParameters(**kwargs) + ) + class TestVenueWithRequest(TestVenueBase): @pytest.mark.parametrize( diff --git a/tests/_files/test_video.py b/tests/_files/test_video.py index 65e510b7174..8e1d985d03b 100644 --- a/tests/_files/test_video.py +++ b/tests/_files/test_video.py @@ -22,7 +22,8 @@ import pytest -from telegram import Bot, InputFile, MessageEntity, PhotoSize, Video, Voice +from telegram import Bot, InputFile, MessageEntity, PhotoSize, ReplyParameters, Video, Voice +from telegram.constants import ParseMode from telegram.error import BadRequest, TelegramError from telegram.helpers import escape_markdown from telegram.request import RequestData @@ -31,6 +32,7 @@ check_shortcut_call, check_shortcut_signature, ) +from tests.auxil.build_messages import make_message from tests.auxil.files import data_file from tests.auxil.slots import mro_slots @@ -201,6 +203,31 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(video.get_bot(), "get_file", make_assertion) assert await video.get_file() + @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_video_default_quote_parse_mode( + self, default_bot, chat_id, video, 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_video(chat_id, video, reply_parameters=ReplyParameters(**kwargs)) + class TestVideoWithRequest(TestVideoBase): async def test_send_all_args(self, bot, chat_id, video_file, video, thumb_file): diff --git a/tests/_files/test_videonote.py b/tests/_files/test_videonote.py index 751bbc03f67..c4cf5437389 100644 --- a/tests/_files/test_videonote.py +++ b/tests/_files/test_videonote.py @@ -22,7 +22,8 @@ import pytest -from telegram import Bot, InputFile, PhotoSize, VideoNote, Voice +from telegram import Bot, InputFile, PhotoSize, ReplyParameters, VideoNote, Voice +from telegram.constants import ParseMode from telegram.error import BadRequest, TelegramError from telegram.request import RequestData from tests.auxil.bot_method_checks import ( @@ -30,6 +31,7 @@ check_shortcut_call, check_shortcut_signature, ) +from tests.auxil.build_messages import make_message from tests.auxil.files import data_file from tests.auxil.slots import mro_slots @@ -188,6 +190,33 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(video_note.get_bot(), "get_file", make_assertion) assert await video_note.get_file() + @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_video_note_default_quote_parse_mode( + self, default_bot, chat_id, video_note, 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_video_note( + chat_id, video_note, reply_parameters=ReplyParameters(**kwargs) + ) + class TestVideoNoteWithRequest(TestVideoNoteBase): async def test_send_all_args(self, bot, chat_id, video_note_file, video_note, thumb_file): diff --git a/tests/_files/test_voice.py b/tests/_files/test_voice.py index 13091ff188e..2cf9af77191 100644 --- a/tests/_files/test_voice.py +++ b/tests/_files/test_voice.py @@ -22,7 +22,8 @@ import pytest -from telegram import Audio, Bot, InputFile, MessageEntity, Voice +from telegram import Audio, Bot, InputFile, MessageEntity, ReplyParameters, Voice +from telegram.constants import ParseMode from telegram.error import BadRequest, TelegramError from telegram.helpers import escape_markdown from telegram.request import RequestData @@ -31,6 +32,7 @@ check_shortcut_call, check_shortcut_signature, ) +from tests.auxil.build_messages import make_message from tests.auxil.files import data_file from tests.auxil.slots import mro_slots @@ -175,6 +177,31 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(voice.get_bot(), "get_file", make_assertion) assert await voice.get_file() + @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_voice_default_quote_parse_mode( + self, default_bot, chat_id, voice, 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_voice(chat_id, voice, reply_parameters=ReplyParameters(**kwargs)) + class TestVoiceWithRequest(TestVoiceBase): async def test_send_all_args(self, bot, chat_id, voice_file, voice): diff --git a/tests/_inline/test_inputtextmessagecontent.py b/tests/_inline/test_inputtextmessagecontent.py index e842461bb0e..052df0bbfa9 100644 --- a/tests/_inline/test_inputtextmessagecontent.py +++ b/tests/_inline/test_inputtextmessagecontent.py @@ -18,8 +18,9 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. import pytest -from telegram import InputTextMessageContent, MessageEntity +from telegram import InputTextMessageContent, LinkPreviewOptions, MessageEntity from telegram.constants import ParseMode +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -29,7 +30,7 @@ def input_text_message_content(): TestInputTextMessageContentBase.message_text, parse_mode=TestInputTextMessageContentBase.parse_mode, entities=TestInputTextMessageContentBase.entities, - disable_web_page_preview=TestInputTextMessageContentBase.disable_web_page_preview, + link_preview_options=TestInputTextMessageContentBase.link_preview_options, ) @@ -37,7 +38,8 @@ class TestInputTextMessageContentBase: message_text = "*message text*" parse_mode = ParseMode.MARKDOWN entities = [MessageEntity(MessageEntity.ITALIC, 0, 7)] - disable_web_page_preview = True + disable_web_page_preview = False + link_preview_options = LinkPreviewOptions(False, url="https://python-telegram-bot.org") class TestInputTextMessageContentWithoutRequest(TestInputTextMessageContentBase): @@ -52,6 +54,7 @@ def test_expected_values(self, input_text_message_content): assert input_text_message_content.message_text == self.message_text assert input_text_message_content.disable_web_page_preview == self.disable_web_page_preview assert input_text_message_content.entities == tuple(self.entities) + assert input_text_message_content.link_preview_options == self.link_preview_options def test_entities_always_tuple(self): input_text_message_content = InputTextMessageContent("text") @@ -72,8 +75,8 @@ def test_to_dict(self, input_text_message_content): ce.to_dict() for ce in input_text_message_content.entities ] assert ( - input_text_message_content_dict["disable_web_page_preview"] - == input_text_message_content.disable_web_page_preview + input_text_message_content_dict["link_preview_options"] + == input_text_message_content.link_preview_options.to_dict() ) def test_equality(self): @@ -90,3 +93,15 @@ def test_equality(self): assert a != d assert hash(a) != hash(d) + + def test_mutually_exclusive(self): + with pytest.raises(ValueError, match="'link_preview_options' in Bot API 7.0"): + InputTextMessageContent( + "text", disable_web_page_preview=True, link_preview_options=LinkPreviewOptions() + ) + + def test_disable_web_page_preview_deprecated(self): + with pytest.warns( + PTBDeprecationWarning, match="'disable_web_page_preview' to 'link_preview_options'" + ): + InputTextMessageContent("text", disable_web_page_preview=True).disable_web_page_preview diff --git a/tests/_payment/test_invoice.py b/tests/_payment/test_invoice.py index 4b8f308a19e..6bf290906d2 100644 --- a/tests/_payment/test_invoice.py +++ b/tests/_payment/test_invoice.py @@ -20,9 +20,11 @@ import pytest -from telegram import Invoice, LabeledPrice +from telegram import Invoice, LabeledPrice, ReplyParameters +from telegram.constants import ParseMode from telegram.error import BadRequest from telegram.request import RequestData +from tests.auxil.build_messages import make_message from tests.auxil.slots import mro_slots @@ -167,6 +169,40 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): start_parameter=self.start_parameter, ) + @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_invoice_default_quote_parse_mode( + self, default_bot, chat_id, invoice, custom, monkeypatch, provider_token + ): + 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_invoice( + chat_id, + self.title, + self.description, + self.payload, + provider_token, + self.currency, + self.prices, + reply_parameters=ReplyParameters(**kwargs), + ) + def test_equality(self): a = Invoice("invoice", "desc", "start", "EUR", 7) b = Invoice("invoice", "desc", "start", "EUR", 7) diff --git a/tests/auxil/bot_method_checks.py b/tests/auxil/bot_method_checks.py index 934a3a24ff1..396516f216d 100644 --- a/tests/auxil/bot_method_checks.py +++ b/tests/auxil/bot_method_checks.py @@ -34,6 +34,8 @@ InlineQueryResultCachedPhoto, InputMediaPhoto, InputTextMessageContent, + LinkPreviewOptions, + ReplyParameters, TelegramObject, ) from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue @@ -93,9 +95,18 @@ def resolve_class(class_name: str) -> Optional[type]: expected_args = set(bot_sig.parameters.keys()).difference(shortcut_kwargs) expected_args.discard("self") - args_check = expected_args == effective_shortcut_args - if not args_check: - raise Exception(f"Expected arguments {expected_args}, got {effective_shortcut_args}") + len_expected = len(expected_args) + len_effective = len(effective_shortcut_args) + if len_expected > len_effective: + raise Exception( + f"Shortcut signature is missing {len_expected - len_effective} arguments " + f"of the underlying Bot method: {expected_args - effective_shortcut_args}" + ) + if len_expected < len_effective: + raise Exception( + f"Shortcut signature has {len_effective - len_expected} additional arguments " + f"that the Bot method doesn't have: {effective_shortcut_args - expected_args}" + ) # TODO: Also check annotation of return type. Would currently be a hassle b/c typing doesn't # resolve `ForwardRef('Type')` to `Type`. For now we rely on MyPy, which probably allows the @@ -150,6 +161,9 @@ def resolve_class(class_name: str) -> Optional[type]: ) for kwarg in additional_kwargs: + if kwarg == "reply_to_message_id": + # special case for deprecated argument of Message.reply_* + continue if not shortcut_sig.parameters[kwarg].kind == inspect.Parameter.KEYWORD_ONLY: raise Exception(f"Argument {kwarg} must be a positional-only argument!") @@ -193,14 +207,31 @@ async def check_shortcut_call( shortcut_signature = inspect.signature(shortcut_method) # auto_pagination: Special casing for InlineQuery.answer - kwargs = {name: name for name in shortcut_signature.parameters if name != "auto_pagination"} + # quote: Don't test deprecated "quote" parameter of Message.reply_* + kwargs = { + name: name + for name in shortcut_signature.parameters + if name not in ["auto_pagination", "quote"] + } + if "reply_parameters" in kwargs: + kwargs["reply_parameters"] = ReplyParameters(message_id=1) + + # We tested this for a long time, but Bot API 7.0 deprecated it in favor of + # reply_parameters. In the transition phase, both exist in a mutually exclusive + # way. Testing both cases would require a lot of additional code, so we just + # ignore this parameter here until it is removed. + kwargs.pop("reply_to_message_id", None) + expected_args.discard("reply_to_message_id") async def make_assertion(**kw): # name == value makes sure that # a) we receive non-None input for all parameters # b) we receive the correct input for each kwarg received_kwargs = { - name for name, value in kw.items() if name in ignored_args or value == name + name + for name, value in kw.items() + if name in ignored_args + or (value == name or (name == "reply_parameters" and value.message_id == 1)) } if not received_kwargs == expected_args: raise Exception( @@ -225,7 +256,9 @@ async def make_assertion(**kw): return True -def build_kwargs(signature: inspect.Signature, default_kwargs, dfv: Any = DEFAULT_NONE): +def build_kwargs( + signature: inspect.Signature, default_kwargs, manually_passed_value: Any = DEFAULT_NONE +): kws = {} for name, param in signature.parameters.items(): # For required params we need to pass something @@ -236,19 +269,26 @@ def build_kwargs(signature: inspect.Signature, default_kwargs, dfv: Any = DEFAUL elif name in ["prices", "commands", "errors"]: kws[name] = [] elif name == "media": - media = InputMediaPhoto("media", parse_mode=dfv) + media = InputMediaPhoto("media", parse_mode=manually_passed_value) if "list" in str(param.annotation).lower(): kws[name] = [media] else: kws[name] = media elif name == "results": itmc = InputTextMessageContent( - "text", parse_mode=dfv, disable_web_page_preview=dfv + "text", + parse_mode=manually_passed_value, + link_preview_options=LinkPreviewOptions( + is_disabled=manually_passed_value, url=manually_passed_value + ), ) kws[name] = [ InlineQueryResultArticle("id", "title", input_message_content=itmc), InlineQueryResultCachedPhoto( - "id", "photo_file_id", parse_mode=dfv, input_message_content=itmc + "id", + "photo_file_id", + parse_mode=manually_passed_value, + input_message_content=itmc, ), ] elif name == "ok": @@ -256,31 +296,231 @@ def build_kwargs(signature: inspect.Signature, default_kwargs, dfv: Any = DEFAUL kws["error_message"] = "error" else: kws[name] = True + # pass values for params that can have defaults only if we don't want to use the # standard default elif name in default_kwargs: - if dfv != DEFAULT_NONE: - kws[name] = dfv + if manually_passed_value != DEFAULT_NONE: + if name == "link_preview_options": + kws[name] = LinkPreviewOptions( + is_disabled=manually_passed_value, url=manually_passed_value + ) + else: + kws[name] = manually_passed_value # Some special casing for methods that have "exactly one of the optionals" type args elif name in ["location", "contact", "venue", "inline_message_id"]: kws[name] = True - # Special casing for some methods where the parameter is actually required, but is optional - # for compatibility reasons - # TODO: remove this once these arguments are marked as required - elif name in {"sticker", "stickers", "sticker_format"}: - kws[name] = "something passed" elif name == "until_date": - if dfv == "non-None-value": + if manually_passed_value not in [None, DEFAULT_NONE]: # Europe/Berlin kws[name] = pytz.timezone("Europe/Berlin").localize( datetime.datetime(2000, 1, 1, 0) ) else: - # UTC + # naive UTC kws[name] = datetime.datetime(2000, 1, 1, 0) + elif name == "reply_parameters": + kws[name] = telegram.ReplyParameters( + message_id=1, + allow_sending_without_reply=manually_passed_value, + quote_parse_mode=manually_passed_value, + ) + return kws +def make_assertion_for_link_preview_options( + expected_defaults_value, lpo, manual_value_expected, manually_passed_value +): + if not lpo: + return + + # if no_value_expected: + # # We always expect a value for link_preview_options, because we don't test the + # # case send_message(…, link_preview_options=None). Instead we focus on the more + # # compicated case of send_message(…, link_preview_options=LinkPreviewOptions(arg=None)) + if manual_value_expected: + if lpo.get("is_disabled") != manually_passed_value: + pytest.fail( + f"Got value {lpo.get('is_disabled')} for link_preview_options.is_disabled, " + f"expected it to be {manually_passed_value}" + ) + if lpo.get("url") != manually_passed_value: + pytest.fail( + f"Got value {lpo.get('url')} for link_preview_options.url, " + f"expected it to be {manually_passed_value}" + ) + if expected_defaults_value: + if lpo.get("show_above_text") != expected_defaults_value: + pytest.fail( + f"Got value {lpo.get('show_above_text')} for link_preview_options.show_above_text," + f" expected it to be {expected_defaults_value}" + ) + if manually_passed_value is DEFAULT_NONE and lpo.get("url") != expected_defaults_value: + pytest.fail( + f"Got value {lpo.get('url')} for link_preview_options.url, " + f"expected it to be {expected_defaults_value}" + ) + + +async def make_assertion( + url, + request_data: RequestData, + method_name: str, + kwargs_need_default: List[str], + return_value, + manually_passed_value: Any = DEFAULT_NONE, + expected_defaults_value: Any = DEFAULT_NONE, + *args, + **kwargs, +): + data = request_data.parameters + + no_value_expected = (manually_passed_value is None) or ( + manually_passed_value is DEFAULT_NONE and expected_defaults_value is None + ) + manual_value_expected = (manually_passed_value is not DEFAULT_NONE) and not no_value_expected + default_value_expected = (not manual_value_expected) and (not no_value_expected) + + # Check reply_parameters - needs special handling b/c we merge this with the default + # value for `allow_sending_without_reply` + reply_parameters = data.pop("reply_parameters", None) + if reply_parameters: + for param in ["allow_sending_without_reply", "quote_parse_mode"]: + if no_value_expected and param in reply_parameters: + pytest.fail(f"Got value for reply_parameters.{param}, expected it to be absent") + param_value = reply_parameters.get(param) + if manual_value_expected and param_value != manually_passed_value: + pytest.fail( + f"Got value {param_value} for reply_parameters.{param} " + f"instead of {manually_passed_value}" + ) + elif default_value_expected and param_value != expected_defaults_value: + pytest.fail( + f"Got value {param_value} for reply_parameters.{param} " + f"instead of {expected_defaults_value}" + ) + + # Check link_preview_options - needs special handling b/c we merge this with the default + # values specified in `Defaults.link_preview_options` + make_assertion_for_link_preview_options( + expected_defaults_value, + data.get("link_preview_options", None), + manual_value_expected, + manually_passed_value, + ) + + # Check regular arguments that need defaults + for arg in kwargs_need_default: + if arg == "link_preview_options": + # already handled above + continue + + # 'None' should not be passed along to Telegram + if no_value_expected and arg in data: + pytest.fail(f"Got value {data[arg]} for argument {arg}, expected it to be absent") + + value = data.get(arg, "`not passed at all`") + if manual_value_expected and value != manually_passed_value: + pytest.fail(f"Got value {value} for argument {arg} instead of {manually_passed_value}") + elif default_value_expected and value != expected_defaults_value: + pytest.fail( + f"Got value {value} for argument {arg} instead of {expected_defaults_value}" + ) + + # Check InputMedia (parse_mode can have a default) + def check_input_media(m: Dict): + parse_mode = m.get("parse_mode") + if no_value_expected and parse_mode is not None: + pytest.fail("InputMedia has non-None parse_mode, expected it to be absent") + elif default_value_expected and parse_mode != expected_defaults_value: + pytest.fail( + f"Got value {parse_mode} for InputMedia.parse_mode instead " + f"of {expected_defaults_value}" + ) + elif manual_value_expected and parse_mode != manually_passed_value: + pytest.fail( + f"Got value {parse_mode} for InputMedia.parse_mode instead " + f"of {manually_passed_value}" + ) + + media = data.pop("media", None) + if media: + if isinstance(media, dict) and isinstance(media.get("type", None), InputMediaType): + check_input_media(media) + else: + for m in media: + check_input_media(m) + + # Check InlineQueryResults + results = data.pop("results", []) + for result in results: + if no_value_expected and "parse_mode" in result: + pytest.fail("ILQR has a parse mode, expected it to be absent") + # Here we explicitly use that we only pass ILQRPhoto and ILQRArticle for testing + elif "photo" in result: + parse_mode = result.get("parse_mode") + if manually_passed_value and parse_mode != manually_passed_value: + pytest.fail( + f"Got value {parse_mode} for ILQR.parse_mode instead of " + f"{manually_passed_value}" + ) + elif default_value_expected and parse_mode != expected_defaults_value: + pytest.fail( + f"Got value {parse_mode} for ILQR.parse_mode instead of " + f"{expected_defaults_value}" + ) + + # Here we explicitly use that we only pass InputTextMessageContent for testing + # which has both parse_mode and link_preview_options + imc = result.get("input_message_content") + if not imc: + continue + if no_value_expected and "parse_mode" in imc: + pytest.fail("ILQR.i_m_c has a parse_mode, expected it to be absent") + parse_mode = imc.get("parse_mode") + if manual_value_expected and parse_mode != manually_passed_value: + pytest.fail( + f"Got value {imc.parse_mode} for ILQR.i_m_c.parse_mode " + f"instead of {manual_value_expected}" + ) + elif default_value_expected and parse_mode != expected_defaults_value: + pytest.fail( + f"Got value {imc.parse_mode} for ILQR.i_m_c.parse_mode " + f"instead of {expected_defaults_value}" + ) + + make_assertion_for_link_preview_options( + expected_defaults_value, + imc.get("link_preview_options", None), + manual_value_expected, + manually_passed_value, + ) + + # Check datetime conversion + until_date = data.pop("until_date", None) + if until_date: + if manual_value_expected and until_date != 946681200: + pytest.fail("Non-naive until_date should have been interpreted as Europe/Berlin.") + if not any((manually_passed_value, expected_defaults_value)) and until_date != 946684800: + pytest.fail("Naive until_date should have been interpreted as UTC") + if default_value_expected and until_date != 946702800: + pytest.fail("Naive until_date should have been interpreted as America/New_York") + + if method_name in ["get_file", "get_small_file", "get_big_file"]: + # This is here mainly for PassportFile.get_file, which calls .set_credentials on the + # return value + out = File(file_id="result", file_unique_id="result") + return out.to_dict() + # Otherwise return None by default, as TGObject.de_json/list(None) in [None, []] + # That way we can check what gets passed to Request.post without having to actually + # make a request + # Some methods expect specific output, so we allow to customize that + if isinstance(return_value, TelegramObject): + return return_value.to_dict() + return return_value + + async def check_defaults_handling( method: Callable, bot: Bot, @@ -302,11 +542,18 @@ async def check_defaults_handling( get_updates = method.__name__.lower().replace("_", "") == "getupdates" shortcut_signature = inspect.signature(method) - kwargs_need_default = [ + kwargs_need_default = { kwarg for kwarg, value in shortcut_signature.parameters.items() if isinstance(value.default, DefaultValue) and not kwarg.endswith("_timeout") - ] + } + # We tested this for a long time, but Bot API 7.0 deprecated it in favor of + # reply_parameters. In the transition phase, both exist in a mutually exclusive + # way. Testing both cases would require a lot of additional code, so we just + # ignore this parameter here until it is removed. + # Same for disable_web_page_preview + kwargs_need_default.discard("allow_sending_without_reply") + kwargs_need_default.discard("disable_web_page_preview") if method.__name__.endswith("_media_group"): # the parse_mode is applied to the first media item, and we test this elsewhere @@ -315,136 +562,71 @@ async def check_defaults_handling( defaults_no_custom_defaults = Defaults() kwargs = {kwarg: "custom_default" for kwarg in inspect.signature(Defaults).parameters} kwargs["tzinfo"] = pytz.timezone("America/New_York") + kwargs.pop("disable_web_page_preview") # mutually exclusive with link_preview_options + kwargs.pop("quote") # mutually exclusive with do_quote + kwargs["link_preview_options"] = LinkPreviewOptions( + url="custom_default", show_above_text="custom_default" + ) defaults_custom_defaults = Defaults(**kwargs) expected_return_values = [None, ()] if return_value is None else [return_value] - - async def make_assertion( - url, request_data: RequestData, df_value=DEFAULT_NONE, *args, **kwargs - ): - data = request_data.parameters - - # Check regular arguments that need defaults - for arg in kwargs_need_default: - # 'None' should not be passed along to Telegram - if df_value in [None, DEFAULT_NONE]: - if arg in data: - pytest.fail( - f"Got value {data[arg]} for argument {arg}, expected it to be absent" - ) - else: - value = data.get(arg, "`not passed at all`") - if value != df_value: - pytest.fail(f"Got value {value} for argument {arg} instead of {df_value}") - - # Check InputMedia (parse_mode can have a default) - def check_input_media(m: Dict): - parse_mode = m.get("parse_mode") - if df_value is DEFAULT_NONE: - if parse_mode is not None: - pytest.fail("InputMedia has non-None parse_mode") - elif parse_mode != df_value: - pytest.fail( - f"Got value {parse_mode} for InputMedia.parse_mode instead of {df_value}" - ) - - media = data.pop("media", None) - if media: - if isinstance(media, dict) and isinstance(media.get("type", None), InputMediaType): - check_input_media(media) - else: - for m in media: - check_input_media(m) - - # Check InlineQueryResults - results = data.pop("results", []) - for result in results: - if df_value in [DEFAULT_NONE, None]: - if "parse_mode" in result: - pytest.fail("ILQR has a parse mode, expected it to be absent") - # Here we explicitly use that we only pass ILQRPhoto and ILQRArticle for testing - # so ILQRPhoto is expected to have parse_mode if df_value is not in [DF_NONE, NONE] - elif "photo" in result and result.get("parse_mode") != df_value: - pytest.fail( - f'Got value {result.get("parse_mode")} for ' - f"ILQR.parse_mode instead of {df_value}" - ) - imc = result.get("input_message_content") - if not imc: - continue - for attr in ["parse_mode", "disable_web_page_preview"]: - if df_value in [DEFAULT_NONE, None]: - if attr in imc: - pytest.fail(f"ILQR.i_m_c has a {attr}, expected it to be absent") - # Here we explicitly use that we only pass InputTextMessageContent for testing - # which has both attributes - elif imc.get(attr) != df_value: - pytest.fail( - f"Got value {imc.get(attr)} for ILQR.i_m_c.{attr} instead of {df_value}" - ) - - # Check datetime conversion - until_date = data.pop("until_date", None) - if until_date: - if df_value == "non-None-value" and until_date != 946681200: - pytest.fail("Non-naive until_date was interpreted as Europe/Berlin.") - if df_value is DEFAULT_NONE and until_date != 946684800: - pytest.fail("Naive until_date was not interpreted as UTC") - if df_value == "custom_default" and until_date != 946702800: - pytest.fail("Naive until_date was not interpreted as America/New_York") - - if method.__name__ in ["get_file", "get_small_file", "get_big_file"]: - # This is here mainly for PassportFile.get_file, which calls .set_credentials on the - # return value - out = File(file_id="result", file_unique_id="result") - nonlocal expected_return_values - expected_return_values = [out] - return out.to_dict() - # Otherwise return None by default, as TGObject.de_json/list(None) in [None, []] - # That way we can check what gets passed to Request.post without having to actually - # make a request - # Some methods expect specific output, so we allow to customize that - if isinstance(return_value, TelegramObject): - return return_value.to_dict() - return return_value + if method.__name__ in ["get_file", "get_small_file", "get_big_file"]: + expected_return_values = [File(file_id="result", file_unique_id="result")] request = bot._request[0] if get_updates else bot.request orig_post = request.post try: if raw_bot: - combinations = [(DEFAULT_NONE, None)] + combinations = [(None, None)] else: combinations = [ - (DEFAULT_NONE, defaults_no_custom_defaults), + (None, defaults_no_custom_defaults), ("custom_default", defaults_custom_defaults), ] - for default_value, defaults in combinations: + for expected_defaults_value, defaults in combinations: if not raw_bot: bot._defaults = defaults # 1: test that we get the correct default value, if we don't specify anything - kwargs = build_kwargs( - shortcut_signature, - kwargs_need_default, + kwargs = build_kwargs(shortcut_signature, kwargs_need_default) + assertion_callback = functools.partial( + make_assertion, + kwargs_need_default=kwargs_need_default, + method_name=method.__name__, + return_value=return_value, + expected_defaults_value=expected_defaults_value, ) - assertion_callback = functools.partial(make_assertion, df_value=default_value) request.post = assertion_callback assert await method(**kwargs) in expected_return_values # 2: test that we get the manually passed non-None value - kwargs = build_kwargs(shortcut_signature, kwargs_need_default, dfv="non-None-value") - assertion_callback = functools.partial(make_assertion, df_value="non-None-value") + kwargs = build_kwargs( + shortcut_signature, kwargs_need_default, manually_passed_value="non-None-value" + ) + assertion_callback = functools.partial( + make_assertion, + manually_passed_value="non-None-value", + kwargs_need_default=kwargs_need_default, + method_name=method.__name__, + return_value=return_value, + expected_defaults_value=expected_defaults_value, + ) request.post = assertion_callback assert await method(**kwargs) in expected_return_values # 3: test that we get the manually passed None value kwargs = build_kwargs( - shortcut_signature, - kwargs_need_default, - dfv=None, + shortcut_signature, kwargs_need_default, manually_passed_value=None + ) + assertion_callback = functools.partial( + make_assertion, + manually_passed_value=None, + kwargs_need_default=kwargs_need_default, + method_name=method.__name__, + return_value=return_value, + expected_defaults_value=expected_defaults_value, ) - assertion_callback = functools.partial(make_assertion, df_value=None) request.post = assertion_callback assert await method(**kwargs) in expected_return_values except Exception as exc: diff --git a/tests/auxil/pytest_classes.py b/tests/auxil/pytest_classes.py index 15ade7975c1..52f0e59ad27 100644 --- a/tests/auxil/pytest_classes.py +++ b/tests/auxil/pytest_classes.py @@ -20,7 +20,7 @@ modify behavior of the respective parent classes in order to make them easier to use in the pytest framework. A common change is to allow monkeypatching of the class members by not enforcing slots in the subclasses.""" -from telegram import Bot, User +from telegram import Bot, Message, User from telegram.ext import Application, ExtBot from tests.auxil.ci_bots import BOT_INFO_PROVIDER from tests.auxil.constants import PRIVATE_KEY @@ -85,6 +85,10 @@ class PytestApplication(Application): pass +class PytestMessage(Message): + pass + + def make_bot(bot_info=None, **kwargs): """ Tests are executed on tg.ext.ExtBot, as that class only extends the functionality of tg.bot diff --git a/tests/auxil/string_manipulation.py b/tests/auxil/string_manipulation.py new file mode 100644 index 00000000000..82934396d24 --- /dev/null +++ b/tests/auxil/string_manipulation.py @@ -0,0 +1,15 @@ +import re + + +def to_camel_case(snake_str): + """https://stackoverflow.com/a/19053800""" + components = snake_str.split("_") + # We capitalize the first letter of each component except the first one + # with the 'title' method and join them together. + return components[0] + "".join(x.title() for x in components[1:]) + + +def to_snake_case(camel_str): + """https://stackoverflow.com/a/1176023""" + name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", camel_str) + return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower() diff --git a/tests/conftest.py b/tests/conftest.py index 45728fb5961..ce940bdebb8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -70,6 +70,8 @@ def pytest_runtestloop(session: pytest.Session): def no_rerun_after_xfail_or_flood(error, name, test: pytest.Function, plugin): """Don't rerun tests that have xfailed when marked with xfail, or when we hit a flood limit.""" xfail_present = test.get_closest_marker(name="xfail") + if getattr(error[1], "msg", "") is None: + raise error[1] did_we_flood = "flood" in getattr(error[1], "msg", "") # _pytest.outcomes.XFailed has 'msg' if xfail_present or did_we_flood: return False diff --git a/tests/ext/test_chatboosthandler.py b/tests/ext/test_chatboosthandler.py new file mode 100644 index 00000000000..b9944a42cb1 --- /dev/null +++ b/tests/ext/test_chatboosthandler.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# 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 time + +import pytest + +from telegram import ( + Chat, + ChatBoost, + ChatBoostRemoved, + ChatBoostSourcePremium, + ChatBoostUpdated, + Update, + User, +) +from telegram._utils.datetime import from_timestamp +from telegram.ext import CallbackContext, ChatBoostHandler +from tests.auxil.slots import mro_slots +from tests.test_update import all_types as really_all_types +from tests.test_update import params as all_params + +# Remove "chat_boost" from params +params = [param for param in all_params for key in param if "chat_boost" not in key] +all_types = [param for param in really_all_types if "chat_boost" not in param] +ids = (*all_types, "callback_query_without_message") + + +@pytest.fixture(scope="class", params=params, ids=ids) +def false_update(request): + return Update(update_id=2, **request.param) + + +def chat_boost(): + return ChatBoost( + "1", + from_timestamp(int(time.time())), + from_timestamp(int(time.time())), + ChatBoostSourcePremium( + User(1, "first_name", False), + ), + ) + + +@pytest.fixture(scope="module") +def removed_chat_boost(): + return ChatBoostRemoved( + Chat(1, "group", username="chat"), + "1", + from_timestamp(int(time.time())), + ChatBoostSourcePremium( + User(1, "first_name", False), + ), + ) + + +def removed_chat_boost_update(): + return Update( + update_id=2, + removed_chat_boost=ChatBoostRemoved( + Chat(1, "group", username="chat"), + "1", + from_timestamp(int(time.time())), + ChatBoostSourcePremium( + User(1, "first_name", False), + ), + ), + ) + + +@pytest.fixture(scope="module") +def chat_boost_updated(): + return ChatBoostUpdated(Chat(1, "group", username="chat"), chat_boost()) + + +def chat_boost_updated_update(): + return Update( + update_id=2, + chat_boost=ChatBoostUpdated( + Chat(1, "group", username="chat"), + chat_boost(), + ), + ) + + +class TestChatBoostHandler: + test_flag = False + + def test_slot_behaviour(self): + action = ChatBoostHandler(self.cb_chat_boost_removed) + for attr in action.__slots__: + assert getattr(action, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" + + @pytest.fixture(autouse=True) + def _reset(self): + self.test_flag = False + + async def cb_chat_boost_updated(self, update, context): + self.test_flag = ( + isinstance(context, CallbackContext) + and isinstance(update.chat_boost, ChatBoostUpdated) + and not isinstance(update.removed_chat_boost, ChatBoostRemoved) + ) + + async def cb_chat_boost_removed(self, update, context): + self.test_flag = ( + isinstance(context, CallbackContext) + and isinstance(update.removed_chat_boost, ChatBoostRemoved) + and not isinstance(update.chat_boost, ChatBoostUpdated) + ) + + async def cb_chat_boost_any(self, update, context): + self.test_flag = isinstance(context, CallbackContext) and ( + isinstance(update.removed_chat_boost, ChatBoostRemoved) + or isinstance(update.chat_boost, ChatBoostUpdated) + ) + + @pytest.mark.parametrize( + argnames=["allowed_types", "cb", "expected"], + argvalues=[ + (ChatBoostHandler.CHAT_BOOST, "cb_chat_boost_updated", (True, False)), + (ChatBoostHandler.REMOVED_CHAT_BOOST, "cb_chat_boost_removed", (False, True)), + (ChatBoostHandler.ANY_CHAT_BOOST, "cb_chat_boost_any", (True, True)), + ], + ids=["CHAT_BOOST", "REMOVED_CHAT_BOOST", "ANY_CHAT_MEMBER"], + ) + async def test_chat_boost_types(self, app, cb, expected, allowed_types): + result_1, result_2 = expected + + update_type, other = chat_boost_updated_update(), removed_chat_boost_update() + + handler = ChatBoostHandler(getattr(self, cb), chat_boost_types=allowed_types) + app.add_handler(handler) + + async with app: + assert handler.check_update(update_type) == result_1 + await app.process_update(update_type) + assert self.test_flag == result_1 + + self.test_flag = False + + assert handler.check_update(other) == result_2 + await app.process_update(other) + assert self.test_flag == result_2 + + def test_other_update_types(self, false_update): + handler = ChatBoostHandler(self.cb_chat_boost_removed) + assert not handler.check_update(false_update) + assert not handler.check_update(True) + + async def test_context(self, app): + handler = ChatBoostHandler(self.cb_chat_boost_updated) + app.add_handler(handler) + + async with app: + await app.process_update(chat_boost_updated_update()) + assert self.test_flag + + def test_with_chat_id(self): + update = chat_boost_updated_update() + cb = self.cb_chat_boost_updated + handler = ChatBoostHandler(cb, chat_id=1) + assert handler.check_update(update) + handler = ChatBoostHandler(cb, chat_id=[1]) + assert handler.check_update(update) + handler = ChatBoostHandler(cb, chat_id=2, chat_username="@chat") + assert handler.check_update(update) + + handler = ChatBoostHandler(cb, chat_id=2) + assert not handler.check_update(update) + handler = ChatBoostHandler(cb, chat_id=[2]) + assert not handler.check_update(update) + + def test_with_username(self): + update = removed_chat_boost_update() + cb = self.cb_chat_boost_removed + handler = ChatBoostHandler(cb, chat_boost_types=0, chat_username="chat") + assert handler.check_update(update) + handler = ChatBoostHandler(cb, chat_boost_types=0, chat_username="@chat") + assert handler.check_update(update) + handler = ChatBoostHandler(cb, chat_boost_types=0, chat_username=["chat"]) + assert handler.check_update(update) + handler = ChatBoostHandler(cb, chat_boost_types=0, chat_username=["@chat"]) + assert handler.check_update(update) + handler = ChatBoostHandler( + cb, chat_boost_types=0, chat_id=1, chat_username="@chat_something" + ) + assert handler.check_update(update) + + handler = ChatBoostHandler(cb, chat_boost_types=0, chat_username="chat_b") + assert not handler.check_update(update) + handler = ChatBoostHandler(cb, chat_boost_types=0, chat_username="@chat_b") + assert not handler.check_update(update) + handler = ChatBoostHandler(cb, chat_boost_types=0, chat_username=["chat_b"]) + assert not handler.check_update(update) + handler = ChatBoostHandler(cb, chat_boost_types=0, chat_username=["@chat_b"]) + assert not handler.check_update(update) + + update.removed_chat_boost.chat._unfreeze() + update.removed_chat_boost.chat.username = None + assert not handler.check_update(update) diff --git a/tests/ext/test_defaults.py b/tests/ext/test_defaults.py index e6be6030734..52f32587821 100644 --- a/tests/ext/test_defaults.py +++ b/tests/ext/test_defaults.py @@ -22,8 +22,9 @@ import pytest -from telegram import User +from telegram import LinkPreviewOptions, User from telegram.ext import Defaults +from telegram.warnings import PTBDeprecationWarning from tests.auxil.envvars import TEST_WITH_OPT_DEPS from tests.auxil.slots import mro_slots @@ -50,11 +51,13 @@ def test_data_assignment(self): setattr(defaults, name, True) def test_equality(self): - a = Defaults(parse_mode="HTML", quote=True) - b = Defaults(parse_mode="HTML", quote=True) - c = Defaults(parse_mode="HTML", quote=True, protect_content=True) + a = Defaults(parse_mode="HTML", do_quote=True) + b = Defaults(parse_mode="HTML", do_quote=True) + c = Defaults(parse_mode="HTML", do_quote=True, protect_content=True) d = Defaults(parse_mode="HTML", protect_content=True) e = User(123, "test_user", False) + f = Defaults(parse_mode="HTML", disable_web_page_preview=True) + g = Defaults(parse_mode="HTML", disable_web_page_preview=True) assert a == b assert hash(a) == hash(b) @@ -68,3 +71,32 @@ def test_equality(self): assert a != e assert hash(a) != hash(e) + + assert f == g + assert hash(f) == hash(g) + + def test_mutually_exclusive(self): + with pytest.raises(ValueError, match="mutually exclusive"): + Defaults(disable_web_page_preview=True, link_preview_options=LinkPreviewOptions(False)) + with pytest.raises(ValueError, match="mutually exclusive"): + Defaults(quote=True, do_quote=False) + + def test_deprecation_warning_for_disable_web_page_preview(self): + with pytest.warns( + PTBDeprecationWarning, match="`Defaults.disable_web_page_preview` is " + ) as record: + Defaults(disable_web_page_preview=True) + + assert record[0].filename == __file__, "wrong stacklevel!" + + assert Defaults(disable_web_page_preview=True).link_preview_options.is_disabled is True + assert Defaults(disable_web_page_preview=False).disable_web_page_preview is False + + def test_deprecation_warning_for_quote(self): + with pytest.warns(PTBDeprecationWarning, match="`Defaults.quote` is ") as record: + Defaults(quote=True) + + assert record[0].filename == __file__, "wrong stacklevel!" + + assert Defaults(quote=True).do_quote is True + assert Defaults(quote=False).quote is False diff --git a/tests/ext/test_filters.py b/tests/ext/test_filters.py index 0ac7023bb2d..bc848a17a19 100644 --- a/tests/ext/test_filters.py +++ b/tests/ext/test_filters.py @@ -30,6 +30,10 @@ File, Message, MessageEntity, + MessageOriginChannel, + MessageOriginChat, + MessageOriginHiddenUser, + MessageOriginUser, Sticker, SuccessfulPayment, Update, @@ -50,8 +54,9 @@ def update(): from_user=User(0, "Testuser", False), via_bot=User(0, "Testbot", True), sender_chat=Chat(0, "Channel"), - forward_from=User(0, "HAL9000", False), - forward_from_chat=Chat(0, "Channel"), + forward_origin=MessageOriginUser( + datetime.datetime.utcnow(), User(0, "Testuser", False) + ), ), ) update._unfreeze() @@ -60,8 +65,8 @@ def update(): update.message.from_user._unfreeze() update.message.via_bot._unfreeze() update.message.sender_chat._unfreeze() - update.message.forward_from._unfreeze() - update.message.forward_from_chat._unfreeze() + update.message.forward_origin._unfreeze() + update.message.forward_origin.sender_user._unfreeze() return update @@ -79,6 +84,11 @@ def base_class(request): return request.param["class"] +@pytest.fixture(scope="class") +def message_origin_user(): + return MessageOriginUser(datetime.datetime.utcnow(), User(1, "TestOther", False)) + + class TestFilters: def test_all_filters_slot_behaviour(self): """ @@ -267,11 +277,11 @@ def test_filters_merged_with_regex(self, update): result = (filters.COMMAND | filters.Regex(r"linked param")).check_update(update) assert result is True - def test_regex_complex_merges(self, update): + def test_regex_complex_merges(self, update, message_origin_user): sre_type = type(re.match("", "")) update.message.text = "test it out" test_filter = filters.Regex("test") & ( - (filters.StatusUpdate.ALL | filters.FORWARDED) | filters.Regex("out") + (filters.StatusUpdate.ALL | filters.AUDIO) | filters.Regex("out") ) result = test_filter.check_update(update) assert result @@ -280,7 +290,7 @@ def test_regex_complex_merges(self, update): assert isinstance(matches, list) assert len(matches) == 2 assert all(type(res) is sre_type for res in matches) - update.message.forward_date = datetime.datetime.utcnow() + update.message.audio = "test" result = test_filter.check_update(update) assert result assert isinstance(result, dict) @@ -294,7 +304,7 @@ def test_regex_complex_merges(self, update): matches = result["matches"] assert isinstance(matches, list) assert all(type(res) is sre_type for res in matches) - update.message.forward_date = None + update.message.audio = None result = test_filter.check_update(update) assert not result update.message.text = "test it out" @@ -316,7 +326,7 @@ def test_regex_complex_merges(self, update): assert not result update.message.text = "test it out" - update.message.forward_date = None + update.message.forward_origin = None update.message.pinned_message = None test_filter = (filters.Regex("test") | filters.COMMAND) & ( filters.Regex("it") | filters.StatusUpdate.ALL @@ -474,11 +484,11 @@ def test_filters_merged_with_caption_regex(self, update): result = (filters.COMMAND | filters.CaptionRegex(r"linked param")).check_update(update) assert result is True - def test_caption_regex_complex_merges(self, update): + def test_caption_regex_complex_merges(self, update, message_origin_user): sre_type = type(re.match("", "")) update.message.caption = "test it out" test_filter = filters.CaptionRegex("test") & ( - (filters.StatusUpdate.ALL | filters.FORWARDED) | filters.CaptionRegex("out") + (filters.StatusUpdate.ALL | filters.AUDIO) | filters.CaptionRegex("out") ) result = test_filter.check_update(update) assert result @@ -487,7 +497,7 @@ def test_caption_regex_complex_merges(self, update): assert isinstance(matches, list) assert len(matches) == 2 assert all(type(res) is sre_type for res in matches) - update.message.forward_date = datetime.datetime.utcnow() + update.message.audio = "test" result = test_filter.check_update(update) assert result assert isinstance(result, dict) @@ -501,7 +511,7 @@ def test_caption_regex_complex_merges(self, update): matches = result["matches"] assert isinstance(matches, list) assert all(type(res) is sre_type for res in matches) - update.message.forward_date = None + update.message.audio = None result = test_filter.check_update(update) assert not result update.message.caption = "test it out" @@ -523,7 +533,7 @@ def test_caption_regex_complex_merges(self, update): assert not result update.message.caption = "test it out" - update.message.forward_date = None + update.message.forward_origin = None update.message.pinned_message = None test_filter = (filters.CaptionRegex("test") | filters.COMMAND) & ( filters.CaptionRegex("it") | filters.StatusUpdate.ALL @@ -1055,20 +1065,37 @@ def test_filters_status_update(self, update): assert filters.StatusUpdate.WRITE_ACCESS_ALLOWED.check_update(update) update.message.write_access_allowed = None - update.message.user_shared = "user_shared" + update.message._user_shared = "user_shared" assert filters.StatusUpdate.ALL.check_update(update) assert filters.StatusUpdate.USER_SHARED.check_update(update) - update.message.user_shared = None + update.message._user_shared = None + + update.message.users_shared = "users_shared" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.USERS_SHARED.check_update(update) + update.message.users_shared = None update.message.chat_shared = "user_shared" assert filters.StatusUpdate.ALL.check_update(update) assert filters.StatusUpdate.CHAT_SHARED.check_update(update) update.message.chat_shared = None - def test_filters_forwarded(self, update): - assert not filters.FORWARDED.check_update(update) - update.message.forward_date = datetime.datetime.utcnow() + update.message.giveaway_created = "giveaway_created" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.GIVEAWAY_CREATED.check_update(update) + update.message.giveaway_created = None + + update.message.giveaway_completed = "giveaway_completed" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.GIVEAWAY_COMPLETED.check_update(update) + update.message.giveaway_completed = None + + def test_filters_forwarded(self, update, message_origin_user): assert filters.FORWARDED.check_update(update) + update.message.forward_origin = MessageOriginHiddenUser(datetime.datetime.utcnow(), 1) + assert filters.FORWARDED.check_update(update) + update.message.forward_origin = None + assert not filters.FORWARDED.check_update(update) def test_filters_game(self, update): assert not filters.GAME.check_update(update) @@ -1434,57 +1461,88 @@ def test_filters_forwarded_from_allow_empty(self, update): assert not filters.ForwardedFrom().check_update(update) assert filters.ForwardedFrom(allow_empty=True).check_update(update) + update.message.forward_origin = MessageOriginHiddenUser(date=1, sender_user_name="test") + assert not filters.ForwardedFrom(allow_empty=True).check_update(update) + def test_filters_forwarded_from_id(self, update): # Test with User id- assert not filters.ForwardedFrom(chat_id=1).check_update(update) - update.message.forward_from.id = 1 + update.message.forward_origin.sender_user.id = 1 assert filters.ForwardedFrom(chat_id=1).check_update(update) - update.message.forward_from.id = 2 + update.message.forward_origin.sender_user.id = 2 assert filters.ForwardedFrom(chat_id=[1, 2]).check_update(update) assert not filters.ForwardedFrom(chat_id=[3, 4]).check_update(update) - update.message.forward_from = None + update.message.forward_origin = None assert not filters.ForwardedFrom(chat_id=[3, 4]).check_update(update) # Test with Chat id- - update.message.forward_from_chat.id = 4 + update.message.forward_origin = MessageOriginChat(date=1, sender_chat=Chat(4, "test")) assert filters.ForwardedFrom(chat_id=[4]).check_update(update) assert filters.ForwardedFrom(chat_id=[3, 4]).check_update(update) - update.message.forward_from_chat.id = 2 + update.message.forward_origin = MessageOriginChat(date=1, sender_chat=Chat(2, "test")) assert not filters.ForwardedFrom(chat_id=[3, 4]).check_update(update) assert filters.ForwardedFrom(chat_id=2).check_update(update) - update.message.forward_from_chat = None + + # Test with Channel id- + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(4, "test"), message_id=1 + ) + assert filters.ForwardedFrom(chat_id=[4]).check_update(update) + assert filters.ForwardedFrom(chat_id=[3, 4]).check_update(update) + + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(2, "test"), message_id=1 + ) + assert not filters.ForwardedFrom(chat_id=[3, 4]).check_update(update) + assert filters.ForwardedFrom(chat_id=2).check_update(update) + update.message.forward_origin = None def test_filters_forwarded_from_username(self, update): # For User username assert not filters.ForwardedFrom(username="chat").check_update(update) assert not filters.ForwardedFrom(username="Testchat").check_update(update) - update.message.forward_from.username = "chat@" + update.message.forward_origin.sender_user.username = "chat@" assert filters.ForwardedFrom(username="@chat@").check_update(update) assert filters.ForwardedFrom(username="chat@").check_update(update) assert filters.ForwardedFrom(username=["chat1", "chat@", "chat2"]).check_update(update) assert not filters.ForwardedFrom(username=["@username", "@chat_2"]).check_update(update) - update.message.forward_from = None + update.message.forward_origin = None assert not filters.ForwardedFrom(username=["@username", "@chat_2"]).check_update(update) # For Chat username assert not filters.ForwardedFrom(username="chat").check_update(update) assert not filters.ForwardedFrom(username="Testchat").check_update(update) - update.message.forward_from_chat.username = "chat@" + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(4, username="chat@", type=Chat.SUPERGROUP) + ) assert filters.ForwardedFrom(username="@chat@").check_update(update) assert filters.ForwardedFrom(username="chat@").check_update(update) assert filters.ForwardedFrom(username=["chat1", "chat@", "chat2"]).check_update(update) assert not filters.ForwardedFrom(username=["@username", "@chat_2"]).check_update(update) - update.message.forward_from_chat = None + update.message.forward_origin = None + assert not filters.ForwardedFrom(username=["@username", "@chat_2"]).check_update(update) + + # For Channel username + assert not filters.ForwardedFrom(username="chat").check_update(update) + assert not filters.ForwardedFrom(username="Testchat").check_update(update) + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(4, username="chat@", type=Chat.SUPERGROUP), message_id=1 + ) + assert filters.ForwardedFrom(username="@chat@").check_update(update) + assert filters.ForwardedFrom(username="chat@").check_update(update) + assert filters.ForwardedFrom(username=["chat1", "chat@", "chat2"]).check_update(update) + assert not filters.ForwardedFrom(username=["@username", "@chat_2"]).check_update(update) + update.message.forward_origin = None assert not filters.ForwardedFrom(username=["@username", "@chat_2"]).check_update(update) def test_filters_forwarded_from_change_id(self, update): f = filters.ForwardedFrom(chat_id=1) # For User ids- assert f.chat_ids == {1} - update.message.forward_from.id = 1 + update.message.forward_origin.sender_user.id = 1 assert f.check_update(update) - update.message.forward_from.id = 2 + update.message.forward_origin.sender_user.id = 2 assert not f.check_update(update) f.chat_ids = 2 assert f.chat_ids == {2} @@ -1492,11 +1550,29 @@ def test_filters_forwarded_from_change_id(self, update): # For Chat ids- f = filters.ForwardedFrom(chat_id=1) # reset this - update.message.forward_from = None # and change this to None, only one of them can be True + # and change this to None, only one of them can be True + update.message.forward_origin = None + assert f.chat_ids == {1} + update.message.forward_origin = MessageOriginChat(date=1, sender_chat=Chat(1, "test")) + assert f.check_update(update) + update.message.forward_origin = MessageOriginChat(date=1, sender_chat=Chat(2, "test")) + assert not f.check_update(update) + f.chat_ids = 2 + assert f.chat_ids == {2} + assert f.check_update(update) + + # For Channel ids- + f = filters.ForwardedFrom(chat_id=1) # reset this + # and change this to None, only one of them can be True + update.message.forward_origin = None assert f.chat_ids == {1} - update.message.forward_from_chat.id = 1 + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(1, "test"), message_id=1 + ) assert f.check_update(update) - update.message.forward_from_chat.id = 2 + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(2, "test"), message_id=1 + ) assert not f.check_update(update) f.chat_ids = 2 assert f.chat_ids == {2} @@ -1508,19 +1584,37 @@ def test_filters_forwarded_from_change_id(self, update): def test_filters_forwarded_from_change_username(self, update): # For User usernames f = filters.ForwardedFrom(username="chat") - update.message.forward_from.username = "chat" + update.message.forward_origin.sender_user.username = "chat" assert f.check_update(update) - update.message.forward_from.username = "User" + update.message.forward_origin.sender_user.username = "User" assert not f.check_update(update) f.usernames = "User" assert f.check_update(update) # For Chat usernames - update.message.forward_from = None + update.message.forward_origin = None f = filters.ForwardedFrom(username="chat") - update.message.forward_from_chat.username = "chat" + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(1, username="chat", type=Chat.SUPERGROUP) + ) + assert f.check_update(update) + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(2, username="User", type=Chat.SUPERGROUP) + ) + assert not f.check_update(update) + f.usernames = "User" + assert f.check_update(update) + + # For Channel usernames + update.message.forward_origin = None + f = filters.ForwardedFrom(username="chat") + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(1, username="chat", type=Chat.SUPERGROUP), message_id=1 + ) assert f.check_update(update) - update.message.forward_from_chat.username = "User" + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(2, username="User", type=Chat.SUPERGROUP), message_id=1 + ) assert not f.check_update(update) f.usernames = "User" assert f.check_update(update) @@ -1534,28 +1628,50 @@ def test_filters_forwarded_from_add_chat_by_name(self, update): # For User usernames for chat in chats: - update.message.forward_from.username = chat + update.message.forward_origin.sender_user.username = chat assert not f.check_update(update) f.add_usernames("chat_a") f.add_usernames(["chat_b", "chat_c"]) for chat in chats: - update.message.forward_from.username = chat + update.message.forward_origin.sender_user.username = chat assert f.check_update(update) # For Chat usernames - update.message.forward_from = None + update.message.forward_origin = None f = filters.ForwardedFrom() for chat in chats: - update.message.forward_from_chat.username = chat + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(1, username=chat, type=Chat.SUPERGROUP) + ) assert not f.check_update(update) f.add_usernames("chat_a") f.add_usernames(["chat_b", "chat_c"]) for chat in chats: - update.message.forward_from_chat.username = chat + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(1, username=chat, type=Chat.SUPERGROUP) + ) + assert f.check_update(update) + + # For Channel usernames + update.message.forward_origin = None + f = filters.ForwardedFrom() + for chat in chats: + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(1, username=chat, type=Chat.SUPERGROUP), message_id=1 + ) + assert not f.check_update(update) + + f.add_usernames("chat_a") + f.add_usernames(["chat_b", "chat_c"]) + + for chat in chats: + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(1, username=chat, type=Chat.SUPERGROUP), message_id=1 + ) assert f.check_update(update) with pytest.raises(RuntimeError, match="chat_id in conjunction"): @@ -1567,28 +1683,50 @@ def test_filters_forwarded_from_add_chat_by_id(self, update): # For User ids for chat in chats: - update.message.forward_from.id = chat + update.message.forward_origin.sender_user.id = chat assert not f.check_update(update) f.add_chat_ids(1) f.add_chat_ids([2, 3]) for chat in chats: - update.message.forward_from.username = chat + update.message.forward_origin.sender_user.username = chat assert f.check_update(update) # For Chat ids- - update.message.forward_from = None + update.message.forward_origin = None + f = filters.ForwardedFrom() + for chat in chats: + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(chat, "test") + ) + assert not f.check_update(update) + + f.add_chat_ids(1) + f.add_chat_ids([2, 3]) + + for chat in chats: + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(chat, "test") + ) + assert f.check_update(update) + + # For Channel ids- + update.message.forward_origin = None f = filters.ForwardedFrom() for chat in chats: - update.message.forward_from_chat.id = chat + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(chat, "test"), message_id=1 + ) assert not f.check_update(update) f.add_chat_ids(1) f.add_chat_ids([2, 3]) for chat in chats: - update.message.forward_from_chat.username = chat + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(chat, "test"), message_id=1 + ) assert f.check_update(update) with pytest.raises(RuntimeError, match="username in conjunction"): @@ -1603,28 +1741,50 @@ def test_filters_forwarded_from_remove_chat_by_name(self, update): # For User usernames for chat in chats: - update.message.forward_from.username = chat + update.message.forward_origin.sender_user.username = chat assert f.check_update(update) f.remove_usernames("chat_a") f.remove_usernames(["chat_b", "chat_c"]) for chat in chats: - update.message.forward_from.username = chat + update.message.forward_origin.sender_user.username = chat assert not f.check_update(update) # For Chat usernames - update.message.forward_from = None + update.message.forward_origin = None + f = filters.ForwardedFrom(username=chats) + for chat in chats: + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(1, username=chat, type=Chat.SUPERGROUP) + ) + assert f.check_update(update) + + f.remove_usernames("chat_a") + f.remove_usernames(["chat_b", "chat_c"]) + + for chat in chats: + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(1, username=chat, type=Chat.SUPERGROUP) + ) + assert not f.check_update(update) + + # For Channel usernames + update.message.forward_origin = None f = filters.ForwardedFrom(username=chats) for chat in chats: - update.message.forward_from_chat.username = chat + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(1, username=chat, type=Chat.SUPERGROUP), message_id=1 + ) assert f.check_update(update) f.remove_usernames("chat_a") f.remove_usernames(["chat_b", "chat_c"]) for chat in chats: - update.message.forward_from_chat.username = chat + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(1, username=chat, type=Chat.SUPERGROUP), message_id=1 + ) assert not f.check_update(update) def test_filters_forwarded_from_remove_chat_by_id(self, update): @@ -1636,28 +1796,50 @@ def test_filters_forwarded_from_remove_chat_by_id(self, update): # For User ids for chat in chats: - update.message.forward_from.id = chat + update.message.forward_origin.sender_user.id = chat assert f.check_update(update) f.remove_chat_ids(1) f.remove_chat_ids([2, 3]) for chat in chats: - update.message.forward_from.username = chat + update.message.forward_origin.sender_user.username = chat assert not f.check_update(update) # For Chat ids - update.message.forward_from = None + update.message.forward_origin = None + f = filters.ForwardedFrom(chat_id=chats) + for chat in chats: + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(chat, "test") + ) + assert f.check_update(update) + + f.remove_chat_ids(1) + f.remove_chat_ids([2, 3]) + + for chat in chats: + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(chat, "test") + ) + assert not f.check_update(update) + + # For Channel ids + update.message.forward_origin = None f = filters.ForwardedFrom(chat_id=chats) for chat in chats: - update.message.forward_from_chat.id = chat + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(chat, "test"), message_id=1 + ) assert f.check_update(update) f.remove_chat_ids(1) f.remove_chat_ids([2, 3]) for chat in chats: - update.message.forward_from_chat.username = chat + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(chat, "test"), message_id=1 + ) assert not f.check_update(update) def test_filters_forwarded_from_repr(self): @@ -1999,18 +2181,18 @@ def test_language_filter_multiple(self, update): update.message.from_user.language_code = "da" assert f.check_update(update) - def test_and_filters(self, update): + def test_and_filters(self, update, message_origin_user): update.message.text = "test" - update.message.forward_date = datetime.datetime.utcnow() + update.message.forward_origin = message_origin_user assert (filters.TEXT & filters.FORWARDED).check_update(update) update.message.text = "/test" assert (filters.TEXT & filters.FORWARDED).check_update(update) update.message.text = "test" - update.message.forward_date = None + update.message.forward_origin = None assert not (filters.TEXT & filters.FORWARDED).check_update(update) update.message.text = "test" - update.message.forward_date = datetime.datetime.utcnow() + update.message.forward_origin = message_origin_user assert (filters.TEXT & filters.FORWARDED & filters.ChatType.PRIVATE).check_update(update) def test_or_filters(self, update): @@ -2025,9 +2207,9 @@ def test_or_filters(self, update): def test_and_or_filters(self, update): update.message.text = "test" - update.message.forward_date = datetime.datetime.utcnow() + update.message.forward_origin = message_origin_user assert (filters.TEXT & (filters.StatusUpdate.ALL | filters.FORWARDED)).check_update(update) - update.message.forward_date = None + update.message.forward_origin = None assert not (filters.TEXT & (filters.FORWARDED | filters.StatusUpdate.ALL)).check_update( update ) @@ -2057,16 +2239,16 @@ def test_xor_filters_repr(self, update): with pytest.raises(RuntimeError, match="Cannot set name"): (filters.TEXT ^ filters.User(123)).name = "foo" - def test_and_xor_filters(self, update): + def test_and_xor_filters(self, update, message_origin_user): update.message.text = "test" - update.message.forward_date = datetime.datetime.utcnow() + update.message.forward_origin = message_origin_user assert (filters.FORWARDED & (filters.TEXT ^ filters.User(123))).check_update(update) update.message.text = None update.effective_user.id = 123 assert (filters.FORWARDED & (filters.TEXT ^ filters.User(123))).check_update(update) update.message.text = "test" assert not (filters.FORWARDED & (filters.TEXT ^ filters.User(123))).check_update(update) - update.message.forward_date = None + update.message.forward_origin = None update.message.text = None update.effective_user.id = 123 assert not (filters.FORWARDED & (filters.TEXT ^ filters.User(123))).check_update(update) @@ -2079,19 +2261,19 @@ def test_and_xor_filters(self, update): == ">" ) - def test_xor_regex_filters(self, update): + def test_xor_regex_filters(self, update, message_origin_user): sre_type = type(re.match("", "")) update.message.text = "test" - update.message.forward_date = datetime.datetime.utcnow() + update.message.forward_origin = message_origin_user assert not (filters.FORWARDED ^ filters.Regex("^test$")).check_update(update) - update.message.forward_date = None + update.message.forward_origin = None result = (filters.FORWARDED ^ filters.Regex("^test$")).check_update(update) assert result assert isinstance(result, dict) matches = result["matches"] assert isinstance(matches, list) assert type(matches[0]) is sre_type - update.message.forward_date = datetime.datetime.utcnow() + update.message.forward_origin = message_origin_user update.message.text = None assert (filters.FORWARDED ^ filters.Regex("^test$")).check_update(update) is True @@ -2110,15 +2292,15 @@ def test_inverted_filters_repr(self, update): with pytest.raises(RuntimeError, match="Cannot set name"): (~filters.TEXT).name = "foo" - def test_inverted_and_filters(self, update): + def test_inverted_and_filters(self, update, message_origin_user): update.message.text = "/test" update.message.entities = [MessageEntity(MessageEntity.BOT_COMMAND, 0, 5)] - update.message.forward_date = 1 + update.message.forward_origin = message_origin_user assert (filters.FORWARDED & filters.COMMAND).check_update(update) assert not (~filters.FORWARDED & filters.COMMAND).check_update(update) assert not (filters.FORWARDED & ~filters.COMMAND).check_update(update) assert not (~(filters.FORWARDED & filters.COMMAND)).check_update(update) - update.message.forward_date = None + update.message.forward_origin = None assert not (filters.FORWARDED & filters.COMMAND).check_update(update) assert (~filters.FORWARDED & filters.COMMAND).check_update(update) assert not (filters.FORWARDED & ~filters.COMMAND).check_update(update) @@ -2504,3 +2686,17 @@ def test_filters_mention_type_text_mention(self, update): assert not filters.Mention( ["@test3", 123, user_no_username, user_wrong_username] ).check_update(update) + + def test_filters_giveaway(self, update): + assert not filters.GIVEAWAY.check_update(update) + + update.message.giveaway = "test" + assert filters.GIVEAWAY.check_update(update) + assert str(filters.GIVEAWAY) == "filters.GIVEAWAY" + + def test_filters_giveaway_winners(self, update): + assert not filters.GIVEAWAY_WINNERS.check_update(update) + + update.message.giveaway_winners = "test" + assert filters.GIVEAWAY_WINNERS.check_update(update) + assert str(filters.GIVEAWAY_WINNERS) == "filters.GIVEAWAY_WINNERS" diff --git a/tests/ext/test_messagereactionhandler.py b/tests/ext/test_messagereactionhandler.py new file mode 100644 index 00000000000..afeaa9c1d49 --- /dev/null +++ b/tests/ext/test_messagereactionhandler.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2023 +# 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 asyncio +import datetime + +import pytest + +from telegram import ( + Bot, + CallbackQuery, + Chat, + ChosenInlineResult, + Message, + MessageReactionCountUpdated, + MessageReactionUpdated, + PreCheckoutQuery, + ReactionCount, + ReactionTypeEmoji, + ShippingQuery, + Update, + User, +) +from telegram._utils.datetime import UTC +from telegram.ext import CallbackContext, JobQueue, MessageReactionHandler +from tests.auxil.slots import mro_slots + +message = Message(1, None, Chat(1, ""), from_user=User(1, "", False), text="Text") + +params = [ + {"message": message}, + {"edited_message": message}, + {"callback_query": CallbackQuery(1, User(1, "", False), "chat", message=message)}, + {"channel_post": message}, + {"edited_channel_post": message}, + {"chosen_inline_result": ChosenInlineResult("id", User(1, "", False), "")}, + {"shipping_query": ShippingQuery("id", User(1, "", False), "", None)}, + {"pre_checkout_query": PreCheckoutQuery("id", User(1, "", False), "", 0, "")}, + {"callback_query": CallbackQuery(1, User(1, "", False), "chat")}, +] + +ids = ( + "message", + "edited_message", + "callback_query", + "channel_post", + "edited_channel_post", + "chosen_inline_result", + "shipping_query", + "pre_checkout_query", + "callback_query_without_message", +) + + +@pytest.fixture(scope="class", params=params, ids=ids) +def false_update(request): + return Update(update_id=2, **request.param) + + +@pytest.fixture(scope="class") +def time(): + return datetime.datetime.now(tz=UTC) + + +@pytest.fixture(scope="class") +def message_reaction_updated(time, bot): + mr = MessageReactionUpdated( + chat=Chat(1, Chat.SUPERGROUP), + message_id=1, + date=time, + old_reaction=[ReactionTypeEmoji("👍")], + new_reaction=[ReactionTypeEmoji("👎")], + user=User(1, "user_a", False), + actor_chat=Chat(2, Chat.SUPERGROUP), + ) + mr.set_bot(bot) + mr._unfreeze() + mr.chat._unfreeze() + mr.user._unfreeze() + return mr + + +@pytest.fixture(scope="class") +def message_reaction_count_updated(time, bot): + mr = MessageReactionCountUpdated( + chat=Chat(1, Chat.SUPERGROUP), + message_id=1, + date=time, + reactions=[ + ReactionCount(ReactionTypeEmoji("👍"), 1), + ReactionCount(ReactionTypeEmoji("👎"), 1), + ], + ) + mr.set_bot(bot) + mr._unfreeze() + mr.chat._unfreeze() + return mr + + +@pytest.fixture() +def message_reaction_update(bot, message_reaction_updated): + return Update(0, message_reaction=message_reaction_updated) + + +@pytest.fixture() +def message_reaction_count_update(bot, message_reaction_count_updated): + return Update(0, message_reaction_count=message_reaction_count_updated) + + +class TestMessageReactionHandler: + test_flag = False + + def test_slot_behaviour(self): + action = MessageReactionHandler(self.callback) + for attr in action.__slots__: + assert getattr(action, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" + + @pytest.fixture(autouse=True) + def _reset(self): + self.test_flag = False + + async def callback(self, update: Update, context): + self.test_flag = ( + isinstance(context, CallbackContext) + and isinstance(context.bot, Bot) + and isinstance(update, Update) + and isinstance(context.update_queue, asyncio.Queue) + and isinstance(context.job_queue, JobQueue) + and isinstance(context.user_data, dict if update.effective_user else type(None)) + and isinstance(context.chat_data, dict) + and isinstance(context.bot_data, dict) + and ( + isinstance( + update.message_reaction, + MessageReactionUpdated, + ) + or isinstance(update.message_reaction_count, MessageReactionCountUpdated) + ) + ) + + def test_other_update_types(self, false_update): + handler = MessageReactionHandler(self.callback) + assert not handler.check_update(false_update) + assert not handler.check_update(True) + + async def test_context(self, app, message_reaction_update, message_reaction_count_update): + handler = MessageReactionHandler(callback=self.callback) + app.add_handler(handler) + + async with app: + assert handler.check_update(message_reaction_update) + await app.process_update(message_reaction_update) + assert self.test_flag + + self.test_flag = False + await app.process_update(message_reaction_count_update) + assert self.test_flag + + @pytest.mark.parametrize( + argnames=["allowed_types", "expected"], + argvalues=[ + (MessageReactionHandler.MESSAGE_REACTION_UPDATED, (True, False)), + (MessageReactionHandler.MESSAGE_REACTION_COUNT_UPDATED, (False, True)), + (MessageReactionHandler.MESSAGE_REACTION, (True, True)), + ], + ids=["MESSAGE_REACTION_UPDATED", "MESSAGE_REACTION_COUNT_UPDATED", "MESSAGE_REACTION"], + ) + async def test_message_reaction_types( + self, app, message_reaction_update, message_reaction_count_update, expected, allowed_types + ): + result_1, result_2 = expected + + handler = MessageReactionHandler(self.callback, message_reaction_types=allowed_types) + app.add_handler(handler) + + async with app: + assert handler.check_update(message_reaction_update) == result_1 + await app.process_update(message_reaction_update) + assert self.test_flag == result_1 + + self.test_flag = False + + assert handler.check_update(message_reaction_count_update) == result_2 + await app.process_update(message_reaction_count_update) + assert self.test_flag == result_2 + + @pytest.mark.parametrize( + argnames=["allowed_types", "kwargs"], + argvalues=[ + (MessageReactionHandler.MESSAGE_REACTION_COUNT_UPDATED, {"user_username": "user"}), + (MessageReactionHandler.MESSAGE_REACTION, {"user_id": 123}), + ], + ids=["MESSAGE_REACTION_COUNT_UPDATED", "MESSAGE_REACTION"], + ) + async def test_username_with_anonymous_reaction(self, app, allowed_types, kwargs): + with pytest.raises( + ValueError, match="You can not filter for users and include anonymous reactions." + ): + MessageReactionHandler(self.callback, message_reaction_types=allowed_types, **kwargs) + + @pytest.mark.parametrize( + argnames=["chat_id", "expected"], + argvalues=[(1, True), ([1], True), (2, False), ([2], False)], + ) + async def test_with_chat_ids( + self, chat_id, expected, message_reaction_update, message_reaction_count_update + ): + handler = MessageReactionHandler(self.callback, chat_id=chat_id) + assert handler.check_update(message_reaction_update) == expected + assert handler.check_update(message_reaction_count_update) == expected + + @pytest.mark.parametrize( + argnames=["chat_username"], + argvalues=[("group_a",), ("@group_a",), (["group_a"],), (["@group_a"],)], + ids=["group_a", "@group_a", "['group_a']", "['@group_a']"], + ) + async def test_with_chat_usernames( + self, chat_username, message_reaction_update, message_reaction_count_update + ): + handler = MessageReactionHandler(self.callback, chat_username=chat_username) + assert not handler.check_update(message_reaction_update) + assert not handler.check_update(message_reaction_count_update) + + message_reaction_update.message_reaction.chat.username = "group_a" + message_reaction_count_update.message_reaction_count.chat.username = "group_a" + + assert handler.check_update(message_reaction_update) + assert handler.check_update(message_reaction_count_update) + + message_reaction_update.message_reaction.chat.username = None + message_reaction_count_update.message_reaction_count.chat.username = None + + @pytest.mark.parametrize( + argnames=["user_id", "expected"], + argvalues=[(1, True), ([1], True), (2, False), ([2], False)], + ) + async def test_with_user_ids( + self, user_id, expected, message_reaction_update, message_reaction_count_update + ): + handler = MessageReactionHandler( + self.callback, + user_id=user_id, + message_reaction_types=MessageReactionHandler.MESSAGE_REACTION_UPDATED, + ) + assert handler.check_update(message_reaction_update) == expected + assert not handler.check_update(message_reaction_count_update) + + @pytest.mark.parametrize( + argnames=["user_username"], + argvalues=[("user_a",), ("@user_a",), (["user_a"],), (["@user_a"],)], + ids=["user_a", "@user_a", "['user_a']", "['@user_a']"], + ) + async def test_with_user_usernames( + self, user_username, message_reaction_update, message_reaction_count_update + ): + handler = MessageReactionHandler( + self.callback, + user_username=user_username, + message_reaction_types=MessageReactionHandler.MESSAGE_REACTION_UPDATED, + ) + assert not handler.check_update(message_reaction_update) + assert not handler.check_update(message_reaction_count_update) + + message_reaction_update.message_reaction.user.username = "user_a" + + assert handler.check_update(message_reaction_update) + assert not handler.check_update(message_reaction_count_update) + + message_reaction_update.message_reaction.user.username = None + + async def test_message_reaction_count_with_combination( + self, message_reaction_count_update, message_reaction_update + ): + handler = MessageReactionHandler( + self.callback, + chat_id=2, + chat_username="group_a", + message_reaction_types=MessageReactionHandler.MESSAGE_REACTION, + ) + assert not handler.check_update(message_reaction_count_update) + + message_reaction_count_update.message_reaction_count.chat.id = 2 + message_reaction_update.message_reaction.chat.id = 2 + assert handler.check_update(message_reaction_count_update) + assert handler.check_update(message_reaction_update) + message_reaction_count_update.message_reaction_count.chat.id = 1 + message_reaction_update.message_reaction.chat.id = 1 + + message_reaction_count_update.message_reaction_count.chat.username = "group_a" + message_reaction_update.message_reaction.chat.username = "group_a" + assert handler.check_update(message_reaction_count_update) + assert handler.check_update(message_reaction_update) + message_reaction_count_update.message_reaction_count.chat.username = None + message_reaction_update.message_reaction.chat.username = None + + async def test_message_reaction_with_combination(self, message_reaction_update): + handler = MessageReactionHandler( + self.callback, + chat_id=2, + chat_username="group_a", + user_id=2, + user_username="user_a", + message_reaction_types=MessageReactionHandler.MESSAGE_REACTION_UPDATED, + ) + assert not handler.check_update(message_reaction_update) + + message_reaction_update.message_reaction.chat.id = 2 + assert handler.check_update(message_reaction_update) + message_reaction_update.message_reaction.chat.id = 1 + + message_reaction_update.message_reaction.chat.username = "group_a" + assert handler.check_update(message_reaction_update) + message_reaction_update.message_reaction.chat.username = None + + message_reaction_update.message_reaction.user.id = 2 + assert handler.check_update(message_reaction_update) + message_reaction_update.message_reaction.user.id = 1 + + message_reaction_update.message_reaction.user.username = "user_a" + assert handler.check_update(message_reaction_update) + message_reaction_update.message_reaction.user.username = None diff --git a/tests/test_bot.py b/tests/test_bot.py index 241fd5059e7..991d7e534cb 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -53,9 +53,11 @@ InlineQueryResultVoice, InputFile, InputMediaDocument, + InputMediaPhoto, InputMessageContent, InputTextMessageContent, LabeledPrice, + LinkPreviewOptions, MenuButton, MenuButtonCommands, MenuButtonDefault, @@ -64,6 +66,9 @@ MessageEntity, Poll, PollOption, + ReactionTypeCustomEmoji, + ReactionTypeEmoji, + ReplyParameters, SentWebAppMessage, ShippingOption, Update, @@ -79,6 +84,7 @@ InlineQueryResultType, MenuButtonType, ParseMode, + ReactionEmoji, ) from telegram.error import BadRequest, EndPointNotFound, InvalidToken, NetworkError from telegram.ext import ExtBot, InvalidCallbackData @@ -93,6 +99,9 @@ from tests.auxil.pytest_classes import PytestBot, PytestExtBot, make_bot from tests.auxil.slots import mro_slots +from ._files.test_photo import photo_file +from .auxil.build_messages import make_message + @pytest.fixture() async def message(bot, chat_id): # mostly used in tests for edit_message @@ -174,26 +183,26 @@ def bot_methods(ext_bot=True, include_camel_case=False, include_do_api_request=F ) -class InputMessageContentDWPP(InputMessageContent): +class InputMessageContentLPO(InputMessageContent): """ This is here to cover the case of InputMediaContent classes in testing answer_ilq that have - `disable_web_page_preview` but not `parse_mode`. Unlikely to ever happen, but better be save + `link_preview_options` but not `parse_mode`. Unlikely to ever happen, but better be save than sorry … """ - __slots__ = ("disable_web_page_preview", "entities", "message_text", "parse_mode") + __slots__ = ("entities", "link_preview_options", "message_text", "parse_mode") def __init__( self, message_text: str, - disable_web_page_preview=DEFAULT_NONE, + link_preview_options=DEFAULT_NONE, *, api_kwargs=None, ): super().__init__(api_kwargs=api_kwargs) self._unfreeze() self.message_text = message_text - self.disable_web_page_preview = disable_web_page_preview + self.link_preview_options = link_preview_options class TestBotWithoutRequest: @@ -476,7 +485,10 @@ async def test_defaults_handling( assert await check_defaults_handling(bot_method, bot, return_value=return_value) assert await check_defaults_handling(raw_bot_method, raw_bot, return_value=return_value) - def test_ext_bot_signature(self): + @pytest.mark.parametrize( + ("name", "method"), inspect.getmembers(Bot, predicate=inspect.isfunction) + ) + def test_ext_bot_signature(self, name, method): """ Here we make sure that all methods of ext.ExtBot have the same signature as the corresponding methods of tg.Bot. @@ -488,29 +500,28 @@ def test_ext_bot_signature(self): ) different_hints_per_method = defaultdict(set, {"__setattr__": {"ext_bot"}}) - for name, method in inspect.getmembers(Bot, predicate=inspect.isfunction): - signature = inspect.signature(method) - ext_signature = inspect.signature(getattr(ExtBot, name)) + signature = inspect.signature(method) + ext_signature = inspect.signature(getattr(ExtBot, name)) + assert ( + ext_signature.return_annotation == signature.return_annotation + ), f"Wrong return annotation for method {name}" + assert ( + set(signature.parameters) + == set(ext_signature.parameters) - global_extra_args - extra_args_per_method[name] + ), f"Wrong set of parameters for method {name}" + for param_name, param in signature.parameters.items(): + if param_name in different_hints_per_method[name]: + continue assert ( - ext_signature.return_annotation == signature.return_annotation - ), f"Wrong return annotation for method {name}" + param.annotation == ext_signature.parameters[param_name].annotation + ), f"Wrong annotation for parameter {param_name} of method {name}" assert ( - set(signature.parameters) - == set(ext_signature.parameters) - global_extra_args - extra_args_per_method[name] - ), f"Wrong set of parameters for method {name}" - for param_name, param in signature.parameters.items(): - if param_name in different_hints_per_method[name]: - continue - assert ( - param.annotation == ext_signature.parameters[param_name].annotation - ), f"Wrong annotation for parameter {param_name} of method {name}" - assert ( - param.default == ext_signature.parameters[param_name].default - ), f"Wrong default value for parameter {param_name} of method {name}" - assert ( - param.kind == ext_signature.parameters[param_name].kind - ), f"Wrong parameter kind for parameter {param_name} of method {name}" + param.default == ext_signature.parameters[param_name].default + ), f"Wrong default value for parameter {param_name} of method {name}" + assert ( + param.kind == ext_signature.parameters[param_name].kind + ), f"Wrong parameter kind for parameter {param_name} of method {name}" async def test_unknown_kwargs(self, bot, monkeypatch): async def post(url, request_data: RequestData, *args, **kwargs): @@ -584,7 +595,9 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): "input_message_content": { "message_text": "text", "parse_mode": "Markdown", - "disable_web_page_preview": True, + "link_preview_options": { + "is_disabled": True, + }, }, "type": InlineQueryResultType.ARTICLE, "id": "1", @@ -606,7 +619,9 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): "input_message_content": { "message_text": "text", "parse_mode": "HTML", - "disable_web_page_preview": False, + "link_preview_options": { + "is_disabled": False, + }, }, "type": InlineQueryResultType.ARTICLE, "id": "1", @@ -627,7 +642,9 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): "title": "title", "input_message_content": { "message_text": "text", - "disable_web_page_preview": "False", + "link_preview_options": { + "is_disabled": "False", + }, }, "type": InlineQueryResultType.ARTICLE, "id": "1", @@ -719,7 +736,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): results = [ InlineQueryResultArticle("11", "first", InputTextMessageContent("first")), - InlineQueryResultArticle("12", "second", InputMessageContentDWPP("second")), + InlineQueryResultArticle("12", "second", InputMessageContentLPO("second")), InlineQueryResultDocument( id="123", document_url=( @@ -729,7 +746,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): title="test_result", mime_type="image/png", caption="ptb_logo", - input_message_content=InputMessageContentDWPP("imc"), + input_message_content=InputMessageContentLPO("imc"), ), ] @@ -816,7 +833,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): monkeypatch.setattr(bot.request, "post", make_assertion) results = [ InlineQueryResultArticle("11", "first", InputTextMessageContent("first")), - InlineQueryResultArticle("12", "second", InputMessageContentDWPP("second")), + InlineQueryResultArticle("12", "second", InputMessageContentLPO("second")), InlineQueryResultDocument( id="123", document_url=( @@ -826,7 +843,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): title="test_result", mime_type="image/png", caption="ptb_logo", - input_message_content=InputMessageContentDWPP("imc"), + input_message_content=InputMessageContentLPO("imc"), ), ] @@ -866,26 +883,30 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): { "title": "first", "id": "11", - "type": "article", + "type": InlineQueryResultType.ARTICLE, "input_message_content": { "message_text": "first", "parse_mode": "Markdown", - "disable_web_page_preview": True, + "link_preview_options": { + "is_disabled": True, + }, }, }, { "title": "second", "id": "12", - "type": "article", + "type": InlineQueryResultType.ARTICLE, "input_message_content": { "message_text": "second", - "disable_web_page_preview": True, + "link_preview_options": { + "is_disabled": True, + }, }, }, { "title": "test_result", "id": "123", - "type": "document", + "type": InlineQueryResultType.DOCUMENT, "document_url": ( "https://raw.githubusercontent.com/" "python-telegram-bot/logos/master/logo/png/" @@ -896,7 +917,9 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): "parse_mode": "Markdown", "input_message_content": { "message_text": "imc", - "disable_web_page_preview": True, + "link_preview_options": { + "is_disabled": True, + }, "parse_mode": "Markdown", }, }, @@ -909,7 +932,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): monkeypatch.setattr(default_bot.request, "post", make_assertion) results = [ InlineQueryResultArticle("11", "first", InputTextMessageContent("first")), - InlineQueryResultArticle("12", "second", InputMessageContentDWPP("second")), + InlineQueryResultArticle("12", "second", InputMessageContentLPO("second")), InlineQueryResultDocument( id="123", document_url=( @@ -1036,6 +1059,52 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): 1234, results=inline_results_callback, current_offset=6 ) + async def test_send_edit_message_mutually_exclusive_link_preview(self, bot, chat_id): + """Test that link_preview is mutually exclusive with disable_web_page_preview.""" + with pytest.raises(ValueError, match="'disable_web_page_preview' was renamed to"): + await bot.send_message( + chat_id, "text", disable_web_page_preview=True, link_preview_options="something" + ) + + with pytest.raises(ValueError, match="'disable_web_page_preview' was renamed to"): + await bot.edit_message_text( + "text", chat_id, 1, disable_web_page_preview=True, link_preview_options="something" + ) + + async def test_rtm_aswr_mutually_exclusive_reply_parameters(self, bot, chat_id): + """Test that reply_to_message_id and allow_sending_without_reply are mutually exclusive + with reply_parameters.""" + with pytest.raises(ValueError, match="`reply_to_message_id` and"): + await bot.send_message(chat_id, "text", reply_to_message_id=1, reply_parameters=True) + + with pytest.raises(ValueError, match="`allow_sending_without_reply` and"): + await bot.send_message( + chat_id, "text", allow_sending_without_reply=True, reply_parameters=True + ) + + # Test with copy message + with pytest.raises(ValueError, match="`reply_to_message_id` and"): + await bot.copy_message( + chat_id, chat_id, 1, reply_to_message_id=1, reply_parameters=True + ) + + with pytest.raises(ValueError, match="`allow_sending_without_reply` and"): + await bot.copy_message( + chat_id, chat_id, 1, allow_sending_without_reply=True, reply_parameters=True + ) + + # Test with send media group + media = InputMediaPhoto(photo_file) + with pytest.raises(ValueError, match="`reply_to_message_id` and"): + await bot.send_media_group( + chat_id, media, reply_to_message_id=1, reply_parameters=True + ) + + with pytest.raises(ValueError, match="`allow_sending_without_reply` and"): + await bot.send_media_group( + chat_id, media, allow_sending_without_reply=True, reply_parameters=True + ) + # get_file is tested multiple times in the test_*media* modules. # Here we only test the behaviour for bot apis in local mode async def test_get_file_local_mode(self, bot, monkeypatch): @@ -1416,7 +1485,8 @@ async def post(url, request_data: RequestData, *args, **kwargs): data["message_id"] == media_message.message_id, data.get("caption") == caption, data["parse_mode"] == ParseMode.HTML, - data["reply_to_message_id"] == media_message.message_id, + data["reply_parameters"] + == ReplyParameters(message_id=media_message.message_id).to_dict(), ( data["reply_markup"] == keyboard.to_json() if json_keyboard @@ -1794,6 +1864,165 @@ async def post(url, request_data: RequestData, *args, **kwargs): bot.get_my_name(), bot.get_my_name("en"), bot.get_my_name("de") ) == 3 * [BotName(default_name)] + async def test_set_message_reaction(self, bot, monkeypatch): + """This is here so we can test the convenient conversion we do in the function without + having to do multiple requests to Telegram""" + + expected_param = [ + [{"emoji": ReactionEmoji.THUMBS_UP, "type": "emoji"}], + [{"emoji": ReactionEmoji.RED_HEART, "type": "emoji"}], + [{"custom_emoji_id": "custom_emoji_1", "type": "custom_emoji"}], + [{"custom_emoji_id": "custom_emoji_2", "type": "custom_emoji"}], + [{"emoji": ReactionEmoji.THUMBS_DOWN, "type": "emoji"}], + [{"custom_emoji_id": "custom_emoji_3", "type": "custom_emoji"}], + [ + {"emoji": ReactionEmoji.RED_HEART, "type": "emoji"}, + {"custom_emoji_id": "custom_emoji_4", "type": "custom_emoji"}, + {"emoji": ReactionEmoji.THUMBS_DOWN, "type": "emoji"}, + {"custom_emoji_id": "custom_emoji_5", "type": "custom_emoji"}, + ], + [], + ] + + amount = 0 + + async def post(url, request_data: RequestData, *args, **kwargs): + # The mock-post now just fetches the predefined responses from the queues + assert request_data.json_parameters["chat_id"] == "1" + assert request_data.json_parameters["message_id"] == "2" + assert request_data.json_parameters["is_big"] + nonlocal amount + assert request_data.parameters["reaction"] == expected_param[amount] + amount += 1 + + monkeypatch.setattr(bot.request, "post", post) + await bot.set_message_reaction(1, 2, [ReactionTypeEmoji(ReactionEmoji.THUMBS_UP)], True) + await bot.set_message_reaction(1, 2, ReactionTypeEmoji(ReactionEmoji.RED_HEART), True) + await bot.set_message_reaction(1, 2, [ReactionTypeCustomEmoji("custom_emoji_1")], True) + await bot.set_message_reaction(1, 2, ReactionTypeCustomEmoji("custom_emoji_2"), True) + await bot.set_message_reaction(1, 2, ReactionEmoji.THUMBS_DOWN, True) + await bot.set_message_reaction(1, 2, "custom_emoji_3", True) + await bot.set_message_reaction( + 1, + 2, + [ + ReactionTypeEmoji(ReactionEmoji.RED_HEART), + ReactionTypeCustomEmoji("custom_emoji_4"), + ReactionEmoji.THUMBS_DOWN, + ReactionTypeCustomEmoji("custom_emoji_5"), + ], + True, + ) + + @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_message_default_quote_parse_mode( + self, default_bot, chat_id, message, 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_message( + chat_id, message, reply_parameters=ReplyParameters(**kwargs) + ) + + @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_poll_default_quote_parse_mode( + self, default_bot, chat_id, 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_poll( + chat_id, + question="question", + options=["option1", "option2"], + reply_parameters=ReplyParameters(**kwargs), + ) + + @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_game_default_quote_parse_mode( + self, default_bot, chat_id, 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_game( + chat_id, "game_short_name", reply_parameters=ReplyParameters(**kwargs) + ) + + @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_copy_message_default_quote_parse_mode( + self, default_bot, chat_id, 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.copy_message(chat_id, 1, 1, reply_parameters=ReplyParameters(**kwargs)) + async def test_do_api_request_camel_case_conversion(self, bot, monkeypatch): async def make_assertion(url, request_data: RequestData, *args, **kwargs): return url.endswith("camelCase") @@ -1917,6 +2146,41 @@ async def test_forward_protected_message(self, bot, chat_id): result = await tasks assert all("can't be forwarded" in str(exc) for exc in result) + async def test_forward_messages(self, bot, chat_id): + tasks = asyncio.gather( + bot.send_message(chat_id, text="will be forwarded"), + bot.send_message(chat_id, text="will be forwarded"), + ) + + msg1, msg2 = await tasks + + forward_messages = await bot.forward_messages( + chat_id, from_chat_id=chat_id, message_ids=(msg1.message_id, msg2.message_id) + ) + + assert isinstance(forward_messages, tuple) + + tasks = asyncio.gather( + bot.send_message( + chat_id, "temp 1", reply_to_message_id=forward_messages[0].message_id + ), + bot.send_message( + chat_id, "temp 2", reply_to_message_id=forward_messages[1].message_id + ), + ) + + temp_msg1, temp_msg2 = await tasks + forward_msg1 = temp_msg1.reply_to_message + forward_msg2 = temp_msg2.reply_to_message + + assert forward_msg1.text == msg1.text + assert forward_msg1.forward_from.username == msg1.from_user.username + assert isinstance(forward_msg1.forward_date, dtm.datetime) + + assert forward_msg2.text == msg2.text + assert forward_msg2.forward_from.username == msg2.from_user.username + assert isinstance(forward_msg2.forward_date, dtm.datetime) + async def test_delete_message(self, bot, chat_id): message = await bot.send_message(chat_id, text="will be deleted") await asyncio.sleep(2) @@ -1929,8 +2193,15 @@ async def test_delete_message_old_message(self, bot, chat_id): await bot.delete_message(chat_id=chat_id, message_id=1) # send_photo, send_audio, send_document, send_sticker, send_video, send_voice, send_video_note, - # send_media_group and send_animation are tested in their respective test modules. No need to - # duplicate here. + # send_media_group, send_animation, get_user_chat_boosts are tested in their respective + # test modules. No need to duplicate here. + + async def test_delete_messages(self, bot, chat_id): + msg1 = await bot.send_message(chat_id, text="will be deleted") + msg2 = await bot.send_message(chat_id, text="will be deleted") + await asyncio.sleep(2) + + assert await bot.delete_messages(chat_id=chat_id, message_ids=(msg1.id, msg2.id)) is True async def test_send_venue(self, bot, chat_id): longitude = -46.788279 @@ -3112,6 +3383,119 @@ async def test_pin_and_unpin_message(self, bot, super_group_id): # get_forum_topic_icon_stickers, edit_forum_topic, general_forum etc... # are tested in the test_forum module. + async def test_send_message_disable_web_page_preview(self, bot, chat_id): + """Test that disable_web_page_preview is substituted for link_preview_options and that + it still works as expected for backward compatability.""" + msg = await bot.send_message( + chat_id, + "https://github.com/python-telegram-bot/python-telegram-bot", + disable_web_page_preview=True, + ) + assert msg.link_preview_options + assert msg.link_preview_options.is_disabled + + async def test_send_message_link_preview_options(self, bot, chat_id): + """Test whether link_preview_options is correctly passed to the API.""" + # btw it is possible to have no url in the text, but set a url for the preview. + msg = await bot.send_message( + chat_id, + "https://github.com/python-telegram-bot/python-telegram-bot", + link_preview_options=LinkPreviewOptions(prefer_small_media=True, show_above_text=True), + ) + assert msg.link_preview_options + assert not msg.link_preview_options.is_disabled + # The prefer_* options aren't very consistent on the client side (big pic shown) + + # they are not returned by the API. + # assert msg.link_preview_options.prefer_small_media + assert msg.link_preview_options.show_above_text + + @pytest.mark.parametrize( + "default_bot", + [{"link_preview_options": LinkPreviewOptions(show_above_text=True)}], + indirect=True, + ) + async def test_send_message_default_link_preview_options(self, default_bot, chat_id): + """Test whether Defaults.link_preview_options is correctly fused with the passed LPO.""" + github_url = "https://github.com/python-telegram-bot/python-telegram-bot" + website = "https://python-telegram-bot.org/" + + # First test just the default passing: + coro1 = default_bot.send_message(chat_id, github_url) + # Next test fusion of both LPOs: + coro2 = default_bot.send_message( + chat_id, + github_url, + link_preview_options=LinkPreviewOptions(url=website, prefer_large_media=True), + ) + # Now test fusion + overriding of passed LPO: + coro3 = default_bot.send_message( + chat_id, + github_url, + link_preview_options=LinkPreviewOptions(show_above_text=False, url=website), + ) + # finally test explicitly setting to None + coro4 = default_bot.send_message(chat_id, github_url, link_preview_options=None) + + msgs = asyncio.gather(coro1, coro2, coro3, coro4) + msg1, msg2, msg3, msg4 = await msgs + assert msg1.link_preview_options + assert msg1.link_preview_options.show_above_text + + assert msg2.link_preview_options + assert msg2.link_preview_options.show_above_text + assert msg2.link_preview_options.url == website + assert msg2.link_preview_options.prefer_large_media # Now works correctly using new url.. + + assert msg3.link_preview_options + assert not msg3.link_preview_options.show_above_text + assert msg3.link_preview_options.url == website + + assert msg4.link_preview_options == LinkPreviewOptions(url=github_url) + + @pytest.mark.parametrize( + "default_bot", + [{"link_preview_options": LinkPreviewOptions(show_above_text=True)}], + indirect=True, + ) + async def test_edit_message_text_default_link_preview_options(self, default_bot, chat_id): + """Test whether Defaults.link_preview_options is correctly fused with the passed LPO.""" + github_url = "https://github.com/python-telegram-bot/python-telegram-bot" + website = "https://python-telegram-bot.org/" + telegram_url = "https://telegram.org" + base_1, base_2, base_3, base_4 = await asyncio.gather( + *(default_bot.send_message(chat_id, telegram_url) for _ in range(4)) + ) + + # First test just the default passing: + coro1 = base_1.edit_text(github_url) + # Next test fusion of both LPOs: + coro2 = base_2.edit_text( + github_url, + link_preview_options=LinkPreviewOptions(url=website, prefer_large_media=True), + ) + # Now test fusion + overriding of passed LPO: + coro3 = base_3.edit_text( + github_url, + link_preview_options=LinkPreviewOptions(show_above_text=False, url=website), + ) + # finally test explicitly setting to None + coro4 = base_4.edit_text(github_url, link_preview_options=None) + + msgs = asyncio.gather(coro1, coro2, coro3, coro4) + msg1, msg2, msg3, msg4 = await msgs + assert msg1.link_preview_options + assert msg1.link_preview_options.show_above_text + + assert msg2.link_preview_options + assert msg2.link_preview_options.show_above_text + assert msg2.link_preview_options.url == website + assert msg2.link_preview_options.prefer_large_media # Now works correctly using new url.. + + assert msg3.link_preview_options + assert not msg3.link_preview_options.show_above_text + assert msg3.link_preview_options.url == website + + assert msg4.link_preview_options == LinkPreviewOptions(url=github_url) async def test_send_message_entities(self, bot, chat_id): test_string = "Italic Bold Code Spoiler" @@ -3349,6 +3733,30 @@ async def test_copy_message_with_default(self, default_bot, chat_id, media_messa else: assert len(message.caption_entities) == 0 + async def test_copy_messages(self, bot, chat_id): + tasks = asyncio.gather( + bot.send_message(chat_id, text="will be copied 1"), + bot.send_message(chat_id, text="will be copied 2"), + ) + msg1, msg2 = await tasks + + copy_messages = await bot.copy_messages( + chat_id, from_chat_id=chat_id, message_ids=(msg1.message_id, msg2.message_id) + ) + assert isinstance(copy_messages, tuple) + + tasks = asyncio.gather( + bot.send_message(chat_id, "temp 1", reply_to_message_id=copy_messages[0].message_id), + bot.send_message(chat_id, "temp 2", reply_to_message_id=copy_messages[1].message_id), + ) + temp_msg1, temp_msg2 = await tasks + + forward_msg1 = temp_msg1.reply_to_message + forward_msg2 = temp_msg2.reply_to_message + + assert forward_msg1.text == msg1.text + assert forward_msg2.text == msg2.text + # Continue testing arbitrary callback data here with actual requests: async def test_replace_callback_data_send_message(self, cdc_bot, chat_id): bot = cdc_bot @@ -3569,6 +3977,11 @@ async def test_set_get_my_short_description(self, bot): bot.get_my_short_description("de"), ) == 3 * [BotShortDescription("")] + async def test_set_message_reaction(self, bot, chat_id, message): + assert await bot.set_message_reaction( + chat_id, message.message_id, ReactionEmoji.THUMBS_DOWN, True + ) + @pytest.mark.parametrize("bot_class", [Bot, ExtBot]) async def test_do_api_request_warning_known_method(self, bot, bot_class): with pytest.warns(PTBDeprecationWarning, match="Please use 'Bot.get_me'") as record: diff --git a/tests/test_callbackquery.py b/tests/test_callbackquery.py index f1b598a775d..7f98c9def0d 100644 --- a/tests/test_callbackquery.py +++ b/tests/test_callbackquery.py @@ -21,7 +21,7 @@ import pytest -from telegram import Audio, Bot, CallbackQuery, Chat, Message, User +from telegram import Audio, Bot, CallbackQuery, Chat, InaccessibleMessage, Message, User from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, @@ -30,7 +30,7 @@ from tests.auxil.slots import mro_slots -@pytest.fixture(params=["message", "inline"]) +@pytest.fixture(params=["message", "inline", "inaccessible_message"]) def callback_query(bot, request): cbq = CallbackQuery( TestCallbackQueryBase.id_, @@ -44,8 +44,13 @@ def callback_query(bot, request): if request.param == "message": cbq.message = TestCallbackQueryBase.message cbq.message.set_bot(bot) - else: + elif request.param == "inline": cbq.inline_message_id = TestCallbackQueryBase.inline_message_id + elif request.param == "inaccessible_message": + cbq.message = InaccessibleMessage( + chat=TestCallbackQueryBase.message.chat, + message_id=TestCallbackQueryBase.message.message_id, + ) return cbq @@ -117,9 +122,9 @@ def test_to_dict(self, callback_query): assert callback_query_dict["id"] == callback_query.id assert callback_query_dict["from"] == callback_query.from_user.to_dict() assert callback_query_dict["chat_instance"] == callback_query.chat_instance - if callback_query.message: + if callback_query.message is not None: assert callback_query_dict["message"] == callback_query.message.to_dict() - else: + elif callback_query.inline_message_id: assert callback_query_dict["inline_message_id"] == callback_query.inline_message_id assert callback_query_dict["data"] == callback_query.data assert callback_query_dict["game_short_name"] == callback_query.game_short_name @@ -160,6 +165,11 @@ async def make_assertion(*_, **kwargs): assert await callback_query.answer() async def test_edit_message_text(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.edit_message_text("test") + return + async def make_assertion(*_, **kwargs): text = kwargs["text"] == "test" ids = self.check_passed_ids(callback_query, kwargs) @@ -187,6 +197,11 @@ async def make_assertion(*_, **kwargs): assert await callback_query.edit_message_text("test") async def test_edit_message_caption(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.edit_message_caption("test") + return + async def make_assertion(*_, **kwargs): caption = kwargs["caption"] == "new caption" ids = self.check_passed_ids(callback_query, kwargs) @@ -214,6 +229,11 @@ async def make_assertion(*_, **kwargs): assert await callback_query.edit_message_caption("new caption") async def test_edit_message_reply_markup(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.edit_message_reply_markup("test") + return + async def make_assertion(*_, **kwargs): reply_markup = kwargs["reply_markup"] == [["1", "2"]] ids = self.check_passed_ids(callback_query, kwargs) @@ -241,6 +261,11 @@ async def make_assertion(*_, **kwargs): assert await callback_query.edit_message_reply_markup([["1", "2"]]) async def test_edit_message_media(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.edit_message_media("test") + return + async def make_assertion(*_, **kwargs): message_media = kwargs.get("media") == [["1", "2"]] ids = self.check_passed_ids(callback_query, kwargs) @@ -268,6 +293,11 @@ async def make_assertion(*_, **kwargs): assert await callback_query.edit_message_media([["1", "2"]]) async def test_edit_message_live_location(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.edit_message_live_location("test") + return + async def make_assertion(*_, **kwargs): latitude = kwargs.get("latitude") == 1 longitude = kwargs.get("longitude") == 2 @@ -296,6 +326,11 @@ async def make_assertion(*_, **kwargs): assert await callback_query.edit_message_live_location(1, 2) async def test_stop_message_live_location(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.stop_message_live_location("test") + return + async def make_assertion(*_, **kwargs): return self.check_passed_ids(callback_query, kwargs) @@ -320,6 +355,11 @@ async def make_assertion(*_, **kwargs): assert await callback_query.stop_message_live_location() async def test_set_game_score(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.set_game_score(user_id=1, score=2) + return + async def make_assertion(*_, **kwargs): user_id = kwargs.get("user_id") == 1 score = kwargs.get("score") == 2 @@ -348,6 +388,11 @@ async def make_assertion(*_, **kwargs): assert await callback_query.set_game_score(1, 2) async def test_get_game_high_scores(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.get_game_high_scores("test") + return + async def make_assertion(*_, **kwargs): user_id = kwargs.get("user_id") == 1 ids = self.check_passed_ids(callback_query, kwargs) @@ -375,6 +420,10 @@ async def make_assertion(*_, **kwargs): assert await callback_query.get_game_high_scores(1) async def test_delete_message(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.delete_message() + return if callback_query.inline_message_id: pytest.skip("Can't delete inline messages") @@ -400,6 +449,10 @@ async def make_assertion(*args, **kwargs): assert await callback_query.delete_message() async def test_pin_message(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.pin_message() + return if callback_query.inline_message_id: pytest.skip("Can't pin inline messages") @@ -421,6 +474,10 @@ async def make_assertion(*args, **kwargs): assert await callback_query.pin_message() async def test_unpin_message(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.unpin_message() + return if callback_query.inline_message_id: pytest.skip("Can't unpin inline messages") @@ -447,6 +504,10 @@ async def make_assertion(*args, **kwargs): assert await callback_query.unpin_message() async def test_copy_message(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.copy_message(1) + return if callback_query.inline_message_id: pytest.skip("Can't copy inline messages") diff --git a/tests/test_chat.py b/tests/test_chat.py index 5d562a08434..c6c19828dee 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -16,13 +16,22 @@ # # 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 time +import datetime import pytest -from telegram import Bot, Chat, ChatLocation, ChatPermissions, Location, User -from telegram._utils.datetime import UTC, from_timestamp -from telegram.constants import ChatAction, ChatType +from telegram import ( + Bot, + Chat, + ChatLocation, + ChatPermissions, + Location, + ReactionTypeCustomEmoji, + ReactionTypeEmoji, + User, +) +from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import ChatAction, ChatType, ReactionEmoji from telegram.helpers import escape_markdown from tests.auxil.bot_method_checks import ( check_defaults_handling, @@ -48,6 +57,7 @@ def chat(bot): location=TestChatBase.location, has_private_forwards=True, has_protected_content=True, + has_visible_history=True, join_to_send_messages=True, join_by_request=True, has_restricted_voice_and_video_messages=True, @@ -57,6 +67,11 @@ def chat(bot): emoji_status_expiration_date=TestChatBase.emoji_status_expiration_date, has_aggressive_anti_spam_enabled=TestChatBase.has_aggressive_anti_spam_enabled, has_hidden_members=TestChatBase.has_hidden_members, + available_reactions=TestChatBase.available_reactions, + accent_color_id=TestChatBase.accent_color_id, + background_custom_emoji_id=TestChatBase.background_custom_emoji_id, + profile_accent_color_id=TestChatBase.profile_accent_color_id, + profile_background_custom_emoji_id=TestChatBase.profile_background_custom_emoji_id, ) chat.set_bot(bot) chat._unfreeze() @@ -81,6 +96,7 @@ class TestChatBase: linked_chat_id = 11880 location = ChatLocation(Location(123, 456), "Barbie World") has_protected_content = True + has_visible_history = True has_private_forwards = True join_to_send_messages = True join_by_request = True @@ -88,9 +104,17 @@ class TestChatBase: is_forum = True active_usernames = ["These", "Are", "Usernames!"] emoji_status_custom_emoji_id = "VeryUniqueCustomEmojiID" - emoji_status_expiration_date = time.time() + emoji_status_expiration_date = datetime.datetime.now(tz=UTC).replace(microsecond=0) has_aggressive_anti_spam_enabled = True has_hidden_members = True + available_reactions = [ + ReactionTypeEmoji(ReactionEmoji.THUMBS_DOWN), + ReactionTypeCustomEmoji("custom_emoji_id"), + ] + accent_color_id = 1 + background_custom_emoji_id = "background_custom_emoji_id" + profile_accent_color_id = 2 + profile_background_custom_emoji_id = "profile_background_custom_emoji_id" class TestChatWithoutRequest(TestChatBase): @@ -112,6 +136,7 @@ def test_de_json(self, bot): "slow_mode_delay": self.slow_mode_delay, "bio": self.bio, "has_protected_content": self.has_protected_content, + "has_visible_history": self.has_visible_history, "has_private_forwards": self.has_private_forwards, "linked_chat_id": self.linked_chat_id, "location": self.location.to_dict(), @@ -123,9 +148,14 @@ def test_de_json(self, bot): "is_forum": self.is_forum, "active_usernames": self.active_usernames, "emoji_status_custom_emoji_id": self.emoji_status_custom_emoji_id, - "emoji_status_expiration_date": self.emoji_status_expiration_date, + "emoji_status_expiration_date": to_timestamp(self.emoji_status_expiration_date), "has_aggressive_anti_spam_enabled": self.has_aggressive_anti_spam_enabled, "has_hidden_members": self.has_hidden_members, + "available_reactions": [reaction.to_dict() for reaction in self.available_reactions], + "accent_color_id": self.accent_color_id, + "background_custom_emoji_id": self.background_custom_emoji_id, + "profile_accent_color_id": self.profile_accent_color_id, + "profile_background_custom_emoji_id": self.profile_background_custom_emoji_id, } chat = Chat.de_json(json_dict, bot) @@ -139,6 +169,7 @@ def test_de_json(self, bot): assert chat.slow_mode_delay == self.slow_mode_delay assert chat.bio == self.bio assert chat.has_protected_content == self.has_protected_content + assert chat.has_visible_history == self.has_visible_history assert chat.has_private_forwards == self.has_private_forwards assert chat.linked_chat_id == self.linked_chat_id assert chat.location.location == self.location.location @@ -155,17 +186,20 @@ def test_de_json(self, bot): assert chat.is_forum == self.is_forum assert chat.active_usernames == tuple(self.active_usernames) assert chat.emoji_status_custom_emoji_id == self.emoji_status_custom_emoji_id - assert chat.emoji_status_expiration_date == from_timestamp( - self.emoji_status_expiration_date - ) + assert chat.emoji_status_expiration_date == (self.emoji_status_expiration_date) assert chat.has_aggressive_anti_spam_enabled == self.has_aggressive_anti_spam_enabled assert chat.has_hidden_members == self.has_hidden_members + assert chat.available_reactions == tuple(self.available_reactions) + assert chat.accent_color_id == self.accent_color_id + assert chat.background_custom_emoji_id == self.background_custom_emoji_id + assert chat.profile_accent_color_id == self.profile_accent_color_id + assert chat.profile_background_custom_emoji_id == self.profile_background_custom_emoji_id def test_de_json_localization(self, bot, raw_bot, tz_bot): json_dict = { "id": self.id_, "type": self.type_, - "emoji_status_expiration_date": self.emoji_status_expiration_date, + "emoji_status_expiration_date": to_timestamp(self.emoji_status_expiration_date), } chat_bot = Chat.de_json(json_dict, bot) chat_bot_raw = Chat.de_json(json_dict, raw_bot) @@ -194,6 +228,7 @@ def test_to_dict(self, chat): assert chat_dict["bio"] == chat.bio assert chat_dict["has_private_forwards"] == chat.has_private_forwards assert chat_dict["has_protected_content"] == chat.has_protected_content + assert chat_dict["has_visible_history"] == chat.has_visible_history assert chat_dict["linked_chat_id"] == chat.linked_chat_id assert chat_dict["location"] == chat.location.to_dict() assert chat_dict["join_to_send_messages"] == chat.join_to_send_messages @@ -205,11 +240,23 @@ def test_to_dict(self, chat): assert chat_dict["is_forum"] == chat.is_forum assert chat_dict["active_usernames"] == list(chat.active_usernames) assert chat_dict["emoji_status_custom_emoji_id"] == chat.emoji_status_custom_emoji_id - assert chat_dict["emoji_status_expiration_date"] == chat.emoji_status_expiration_date + assert chat_dict["emoji_status_expiration_date"] == to_timestamp( + chat.emoji_status_expiration_date + ) assert ( chat_dict["has_aggressive_anti_spam_enabled"] == chat.has_aggressive_anti_spam_enabled ) assert chat_dict["has_hidden_members"] == chat.has_hidden_members + assert chat_dict["available_reactions"] == [ + reaction.to_dict() for reaction in chat.available_reactions + ] + assert chat_dict["accent_color_id"] == chat.accent_color_id + assert chat_dict["background_custom_emoji_id"] == chat.background_custom_emoji_id + assert chat_dict["profile_accent_color_id"] == chat.profile_accent_color_id + assert ( + chat_dict["profile_background_custom_emoji_id"] + == chat.profile_background_custom_emoji_id + ) def test_always_tuples_attributes(self): chat = Chat( @@ -274,6 +321,28 @@ def test_effective_name(self): chat = Chat(id=1, type=Chat.GROUP) assert chat.effective_name is None + async def test_delete_message(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return kwargs["chat_id"] == chat.id and kwargs["message_id"] == 42 + + assert check_shortcut_signature(Chat.delete_message, Bot.delete_message, ["chat_id"], []) + assert await check_shortcut_call(chat.delete_message, chat.get_bot(), "delete_message") + assert await check_defaults_handling(chat.delete_message, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "delete_message", make_assertion) + assert await chat.delete_message(message_id=42) + + async def test_delete_messages(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return kwargs["chat_id"] == chat.id and kwargs["message_ids"] == (42, 43) + + assert check_shortcut_signature(Chat.delete_messages, Bot.delete_messages, ["chat_id"], []) + assert await check_shortcut_call(chat.delete_messages, chat.get_bot(), "delete_messages") + assert await check_defaults_handling(chat.delete_messages, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "delete_messages", make_assertion) + assert await chat.delete_messages(message_ids=(42, 43)) + async def test_send_action(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == chat.id @@ -824,6 +893,36 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(chat.get_bot(), "copy_message", make_assertion) assert await chat.copy_message(chat_id="test_copy", message_id=42) + async def test_instance_method_send_copies(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + from_chat_id = kwargs["from_chat_id"] == "test_copies" + message_ids = kwargs["message_ids"] == (42, 43) + chat_id = kwargs["chat_id"] == chat.id + return from_chat_id and message_ids and chat_id + + assert check_shortcut_signature(Chat.send_copies, Bot.copy_messages, ["chat_id"], []) + assert await check_shortcut_call(chat.send_copies, chat.get_bot(), "copy_messages") + assert await check_defaults_handling(chat.send_copies, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "copy_messages", make_assertion) + assert await chat.send_copies(from_chat_id="test_copies", message_ids=(42, 43)) + + async def test_instance_method_copy_messages(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + from_chat_id = kwargs["from_chat_id"] == chat.id + message_ids = kwargs["message_ids"] == (42, 43) + chat_id = kwargs["chat_id"] == "test_copies" + return from_chat_id and message_ids and chat_id + + assert check_shortcut_signature( + Chat.copy_messages, Bot.copy_messages, ["from_chat_id"], [] + ) + assert await check_shortcut_call(chat.copy_messages, chat.get_bot(), "copy_messages") + assert await check_defaults_handling(chat.copy_messages, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "copy_messages", make_assertion) + assert await chat.copy_messages(chat_id="test_copies", message_ids=(42, 43)) + async def test_instance_method_forward_from(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): chat_id = kwargs["chat_id"] == chat.id @@ -852,6 +951,42 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(chat.get_bot(), "forward_message", make_assertion) assert await chat.forward_to(chat_id="test_forward", message_id=42) + async def test_instance_method_forward_messages_from(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + chat_id = kwargs["chat_id"] == chat.id + message_ids = kwargs["message_ids"] == (42, 43) + from_chat_id = kwargs["from_chat_id"] == "test_forwards" + return from_chat_id and message_ids and chat_id + + assert check_shortcut_signature( + Chat.forward_messages_from, Bot.forward_messages, ["chat_id"], [] + ) + assert await check_shortcut_call( + chat.forward_messages_from, chat.get_bot(), "forward_messages" + ) + assert await check_defaults_handling(chat.forward_messages_from, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "forward_messages", make_assertion) + assert await chat.forward_messages_from(from_chat_id="test_forwards", message_ids=(42, 43)) + + async def test_instance_method_forward_messages_to(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + from_chat_id = kwargs["from_chat_id"] == chat.id + message_ids = kwargs["message_ids"] == (42, 43) + chat_id = kwargs["chat_id"] == "test_forwards" + return from_chat_id and message_ids and chat_id + + assert check_shortcut_signature( + Chat.forward_messages_to, Bot.forward_messages, ["from_chat_id"], [] + ) + assert await check_shortcut_call( + chat.forward_messages_to, chat.get_bot(), "forward_messages" + ) + assert await check_defaults_handling(chat.forward_messages_to, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "forward_messages", make_assertion) + assert await chat.forward_messages_to(chat_id="test_forwards", message_ids=(42, 43)) + async def test_export_invite_link(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == chat.id @@ -1234,6 +1369,43 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(chat.get_bot(), "unhide_general_forum_topic", make_assertion) assert await chat.unhide_general_forum_topic() + async def test_instance_method_get_user_chat_boosts(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + user_id = kwargs["user_id"] == "user_id" + chat_id = kwargs["chat_id"] == chat.id + return chat_id and user_id + + assert check_shortcut_signature( + Chat.get_user_chat_boosts, Bot.get_user_chat_boosts, ["chat_id"], [] + ) + assert await check_shortcut_call( + chat.get_user_chat_boosts, chat.get_bot(), "get_user_chat_boosts" + ) + assert await check_defaults_handling(chat.get_user_chat_boosts, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "get_user_chat_boosts", make_assertion) + assert await chat.get_user_chat_boosts(user_id="user_id") + + async def test_instance_method_set_message_reaction(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + message_id = kwargs["message_id"] == 123 + chat_id = kwargs["chat_id"] == chat.id + reaction = kwargs["reaction"] == [ReactionTypeEmoji(ReactionEmoji.THUMBS_DOWN)] + return chat_id and message_id and reaction and kwargs["is_big"] + + assert check_shortcut_signature( + Chat.set_message_reaction, Bot.set_message_reaction, ["chat_id"], [] + ) + assert await check_shortcut_call( + chat.set_message_reaction, chat.get_bot(), "set_message_reaction" + ) + assert await check_defaults_handling(chat.set_message_reaction, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "set_message_reaction", make_assertion) + assert await chat.set_message_reaction( + 123, [ReactionTypeEmoji(ReactionEmoji.THUMBS_DOWN)], True + ) + def test_mention_html(self): chat = Chat(id=1, type="foo") with pytest.raises(TypeError, match="Can not create a mention to a private group chat"): diff --git a/tests/test_chatboost.py b/tests/test_chatboost.py new file mode 100644 index 00000000000..ee0a5c9e3c3 --- /dev/null +++ b/tests/test_chatboost.py @@ -0,0 +1,544 @@ +# python-telegram-bot - a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# by the python-telegram-bot contributors +# +# 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 datetime +import inspect +from copy import deepcopy + +import pytest + +from telegram import ( + Chat, + ChatBoost, + ChatBoostRemoved, + ChatBoostSource, + ChatBoostSourceGiftCode, + ChatBoostSourceGiveaway, + ChatBoostSourcePremium, + ChatBoostUpdated, + Dice, + User, + UserChatBoosts, +) +from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import ChatBoostSources +from telegram.request import RequestData +from tests.auxil.slots import mro_slots + + +class ChatBoostDefaults: + chat_id = 1 + boost_id = "2" + giveaway_message_id = 3 + is_unclaimed = False + chat = Chat(1, "group") + user = User(1, "user", False) + date = to_timestamp(datetime.datetime.utcnow()) + default_source = ChatBoostSourcePremium(user) + + +@pytest.fixture(scope="module") +def chat_boost_removed(): + return ChatBoostRemoved( + chat=ChatBoostDefaults.chat, + boost_id=ChatBoostDefaults.boost_id, + remove_date=ChatBoostDefaults.date, + source=ChatBoostDefaults.default_source, + ) + + +@pytest.fixture(scope="module") +def chat_boost(): + return ChatBoost( + boost_id=ChatBoostDefaults.boost_id, + add_date=ChatBoostDefaults.date, + expiration_date=ChatBoostDefaults.date, + source=ChatBoostDefaults.default_source, + ) + + +@pytest.fixture(scope="module") +def chat_boost_updated(chat_boost): + return ChatBoostUpdated( + chat=ChatBoostDefaults.chat, + boost=chat_boost, + ) + + +def chat_boost_source_gift_code(): + return ChatBoostSourceGiftCode( + user=ChatBoostDefaults.user, + ) + + +def chat_boost_source_giveaway(): + return ChatBoostSourceGiveaway( + user=ChatBoostDefaults.user, + giveaway_message_id=ChatBoostDefaults.giveaway_message_id, + is_unclaimed=ChatBoostDefaults.is_unclaimed, + ) + + +def chat_boost_source_premium(): + return ChatBoostSourcePremium( + user=ChatBoostDefaults.user, + ) + + +@pytest.fixture(scope="module") +def user_chat_boosts(chat_boost): + return UserChatBoosts( + boosts=[chat_boost], + ) + + +@pytest.fixture() +def chat_boost_source(request): + return request.param() + + +ignored = ["self", "api_kwargs"] + + +def make_json_dict(instance: ChatBoostSource, include_optional_args: bool = False) -> dict: + """Used to make the json dict which we use for testing de_json. Similar to iter_args()""" + json_dict = {"source": instance.source} + sig = inspect.signature(instance.__class__.__init__) + + for param in sig.parameters.values(): + if param.name in ignored: # ignore irrelevant params + continue + + val = getattr(instance, param.name) + if hasattr(val, "to_dict"): # convert the user object or any future ones to dict. + val = val.to_dict() + json_dict[param.name] = val + + return json_dict + + +def iter_args( + instance: ChatBoostSource, de_json_inst: ChatBoostSource, include_optional: bool = False +): + """ + We accept both the regular instance and de_json created instance and iterate over them for + easy one line testing later one. + """ + yield instance.source, de_json_inst.source # yield this here cause it's not available in sig. + + sig = inspect.signature(instance.__class__.__init__) + for param in sig.parameters.values(): + if param.name in ignored: + continue + inst_at, json_at = getattr(instance, param.name), getattr(de_json_inst, param.name) + if isinstance(json_at, datetime.datetime): # Convert datetime to int + json_at = to_timestamp(json_at) + if ( + param.default is not inspect.Parameter.empty and include_optional + ) or param.default is inspect.Parameter.empty: + yield inst_at, json_at + + +@pytest.mark.parametrize( + "chat_boost_source", + [ + chat_boost_source_gift_code, + chat_boost_source_giveaway, + chat_boost_source_premium, + ], + indirect=True, +) +class TestChatBoostSourceTypesWithoutRequest: + def test_slot_behaviour(self, chat_boost_source): + inst = chat_boost_source + 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_required_args(self, bot, chat_boost_source): + cls = chat_boost_source.__class__ + assert cls.de_json({}, bot) is None + assert ChatBoost.de_json({}, bot) is None + + json_dict = make_json_dict(chat_boost_source) + const_boost_source = ChatBoostSource.de_json(json_dict, bot) + assert const_boost_source.api_kwargs == {} + + assert isinstance(const_boost_source, ChatBoostSource) + assert isinstance(const_boost_source, cls) + for chat_mem_type_at, const_chat_mem_at in iter_args( + chat_boost_source, const_boost_source + ): + assert chat_mem_type_at == const_chat_mem_at + + def test_de_json_all_args(self, bot, chat_boost_source): + json_dict = make_json_dict(chat_boost_source, include_optional_args=True) + const_boost_source = ChatBoostSource.de_json(json_dict, bot) + assert const_boost_source.api_kwargs == {} + + assert isinstance(const_boost_source, ChatBoostSource) + assert isinstance(const_boost_source, chat_boost_source.__class__) + for c_mem_type_at, const_c_mem_at in iter_args( + chat_boost_source, const_boost_source, True + ): + assert c_mem_type_at == const_c_mem_at + + def test_de_json_invalid_source(self, chat_boost_source, bot): + json_dict = {"source": "invalid"} + chat_boost_source = ChatBoostSource.de_json(json_dict, bot) + + assert type(chat_boost_source) is ChatBoostSource + assert chat_boost_source.source == "invalid" + + def test_de_json_subclass(self, chat_boost_source, bot): + """This makes sure that e.g. ChatBoostSourcePremium(data, bot) never returns a + ChatBoostSourceGiftCode instance.""" + cls = chat_boost_source.__class__ + json_dict = make_json_dict(chat_boost_source, True) + assert type(cls.de_json(json_dict, bot)) is cls + + def test_to_dict(self, chat_boost_source): + chat_boost_dict = chat_boost_source.to_dict() + + assert isinstance(chat_boost_dict, dict) + assert chat_boost_dict["source"] == chat_boost_source.source + assert chat_boost_dict["user"] == chat_boost_source.user.to_dict() + + for slot in chat_boost_source.__slots__: # additional verification for the optional args + if slot == "user": # we already test "user" above: + continue + assert getattr(chat_boost_source, slot) == chat_boost_dict[slot] + + def test_equality(self, chat_boost_source): + a = ChatBoostSource(source="status") + b = ChatBoostSource(source="status") + c = chat_boost_source + d = deepcopy(chat_boost_source) + e = Dice(4, "emoji") + + 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) + + assert c == d + assert hash(c) == hash(d) + + assert c != e + assert hash(c) != hash(e) + + def test_enum_init(self, chat_boost_source): + cbs = ChatBoostSource(source="foo") + assert cbs.source == "foo" + cbs = ChatBoostSource(source="premium") + assert cbs.source == ChatBoostSources.PREMIUM + + +class TestChatBoostWithoutRequest(ChatBoostDefaults): + def test_slot_behaviour(self, chat_boost): + inst = chat_boost + 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, bot, chat_boost): + json_dict = { + "boost_id": "2", + "add_date": self.date, + "expiration_date": self.date, + "source": self.default_source.to_dict(), + } + cb = ChatBoost.de_json(json_dict, bot) + + assert isinstance(cb, ChatBoost) + assert isinstance(cb.add_date, datetime.datetime) + assert isinstance(cb.expiration_date, datetime.datetime) + assert isinstance(cb.source, ChatBoostSource) + with cb._unfrozen(): + cb.add_date = to_timestamp(cb.add_date) + cb.expiration_date = to_timestamp(cb.expiration_date) + + # We don't compare cbu.boost to self.boost because we have to update the _id_attrs (sigh) + for slot in cb.__slots__: + assert getattr(cb, slot) == getattr(chat_boost, slot), f"attribute {slot} differs" + + def test_de_json_localization(self, bot, raw_bot, tz_bot): + json_dict = { + "boost_id": "2", + "add_date": self.date, + "expiration_date": self.date, + "source": self.default_source.to_dict(), + } + + cb_bot = ChatBoost.de_json(json_dict, bot) + cb_raw = ChatBoost.de_json(json_dict, raw_bot) + cb_tz = ChatBoost.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + message_offset = cb_tz.add_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset(cb_tz.add_date.replace(tzinfo=None)) + + assert cb_raw.add_date.tzinfo == UTC + assert cb_bot.add_date.tzinfo == UTC + assert message_offset == tz_bot_offset + + def test_to_dict(self, chat_boost): + chat_boost_dict = chat_boost.to_dict() + + assert isinstance(chat_boost_dict, dict) + assert chat_boost_dict["boost_id"] == chat_boost.boost_id + assert chat_boost_dict["add_date"] == chat_boost.add_date + assert chat_boost_dict["expiration_date"] == chat_boost.expiration_date + assert chat_boost_dict["source"] == chat_boost.source.to_dict() + + def test_equality(self): + a = ChatBoost( + boost_id="2", + add_date=self.date, + expiration_date=self.date, + source=self.default_source, + ) + b = ChatBoost( + boost_id="2", + add_date=self.date, + expiration_date=self.date, + source=self.default_source, + ) + c = ChatBoost( + boost_id="3", + add_date=self.date, + expiration_date=self.date, + source=self.default_source, + ) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + +class TestChatBoostUpdatedWithoutRequest(ChatBoostDefaults): + def test_slot_behaviour(self, chat_boost_updated): + inst = chat_boost_updated + 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, bot, chat_boost): + json_dict = { + "chat": self.chat.to_dict(), + "boost": { + "boost_id": "2", + "add_date": self.date, + "expiration_date": self.date, + "source": self.default_source.to_dict(), + }, + } + cbu = ChatBoostUpdated.de_json(json_dict, bot) + + assert isinstance(cbu, ChatBoostUpdated) + assert cbu.chat == self.chat + # We don't compare cbu.boost to chat_boost because we have to update the _id_attrs (sigh) + with cbu.boost._unfrozen(): + cbu.boost.add_date = to_timestamp(cbu.boost.add_date) + cbu.boost.expiration_date = to_timestamp(cbu.boost.expiration_date) + for slot in cbu.boost.__slots__: # Assumes _id_attrs are same as slots + assert getattr(cbu.boost, slot) == getattr(chat_boost, slot), f"attr {slot} differs" + + # no need to test localization since that is already tested in the above class. + + def test_to_dict(self, chat_boost_updated): + chat_boost_updated_dict = chat_boost_updated.to_dict() + + assert isinstance(chat_boost_updated_dict, dict) + assert chat_boost_updated_dict["chat"] == chat_boost_updated.chat.to_dict() + assert chat_boost_updated_dict["boost"] == chat_boost_updated.boost.to_dict() + + def test_equality(self): + a = ChatBoostUpdated( + chat=Chat(1, "group"), + boost=ChatBoost( + boost_id="2", + add_date=self.date, + expiration_date=self.date, + source=self.default_source, + ), + ) + b = ChatBoostUpdated( + chat=Chat(1, "group"), + boost=ChatBoost( + boost_id="2", + add_date=self.date, + expiration_date=self.date, + source=self.default_source, + ), + ) + c = ChatBoostUpdated( + chat=Chat(2, "group"), + boost=ChatBoost( + boost_id="3", + add_date=self.date, + expiration_date=self.date, + source=self.default_source, + ), + ) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + +class TestChatBoostRemovedWithoutRequest(ChatBoostDefaults): + def test_slot_behaviour(self, chat_boost_removed): + inst = chat_boost_removed + 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, bot, chat_boost_removed): + json_dict = { + "chat": self.chat.to_dict(), + "boost_id": "2", + "remove_date": self.date, + "source": self.default_source.to_dict(), + } + cbr = ChatBoostRemoved.de_json(json_dict, bot) + + assert isinstance(cbr, ChatBoostRemoved) + assert cbr.chat == self.chat + assert cbr.boost_id == self.boost_id + assert to_timestamp(cbr.remove_date) == self.date + assert cbr.source == self.default_source + + def test_de_json_localization(self, bot, raw_bot, tz_bot): + json_dict = { + "chat": self.chat.to_dict(), + "boost_id": "2", + "remove_date": self.date, + "source": self.default_source.to_dict(), + } + + cbr_bot = ChatBoostRemoved.de_json(json_dict, bot) + cbr_raw = ChatBoostRemoved.de_json(json_dict, raw_bot) + cbr_tz = ChatBoostRemoved.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + message_offset = cbr_tz.remove_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset(cbr_tz.remove_date.replace(tzinfo=None)) + + assert cbr_raw.remove_date.tzinfo == UTC + assert cbr_bot.remove_date.tzinfo == UTC + assert message_offset == tz_bot_offset + + def test_to_dict(self, chat_boost_removed): + chat_boost_removed_dict = chat_boost_removed.to_dict() + + assert isinstance(chat_boost_removed_dict, dict) + assert chat_boost_removed_dict["chat"] == chat_boost_removed.chat.to_dict() + assert chat_boost_removed_dict["boost_id"] == chat_boost_removed.boost_id + assert chat_boost_removed_dict["remove_date"] == chat_boost_removed.remove_date + assert chat_boost_removed_dict["source"] == chat_boost_removed.source.to_dict() + + def test_equality(self): + a = ChatBoostRemoved( + chat=Chat(1, "group"), + boost_id="2", + remove_date=self.date, + source=self.default_source, + ) + b = ChatBoostRemoved( + chat=Chat(1, "group"), + boost_id="2", + remove_date=self.date, + source=self.default_source, + ) + c = ChatBoostRemoved( + chat=Chat(2, "group"), + boost_id="3", + remove_date=self.date, + source=self.default_source, + ) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + +class TestUserChatBoostsWithoutRequest(ChatBoostDefaults): + def test_slot_behaviour(self, user_chat_boosts): + inst = user_chat_boosts + 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, bot, user_chat_boosts): + json_dict = { + "boosts": [ + { + "boost_id": "2", + "add_date": self.date, + "expiration_date": self.date, + "source": self.default_source.to_dict(), + } + ] + } + ucb = UserChatBoosts.de_json(json_dict, bot) + + assert isinstance(ucb, UserChatBoosts) + assert isinstance(ucb.boosts[0], ChatBoost) + assert ucb.boosts[0].boost_id == self.boost_id + assert to_timestamp(ucb.boosts[0].add_date) == self.date + assert to_timestamp(ucb.boosts[0].expiration_date) == self.date + assert ucb.boosts[0].source == self.default_source + + def test_to_dict(self, user_chat_boosts): + user_chat_boosts_dict = user_chat_boosts.to_dict() + + assert isinstance(user_chat_boosts_dict, dict) + assert isinstance(user_chat_boosts_dict["boosts"], list) + assert user_chat_boosts_dict["boosts"][0] == user_chat_boosts.boosts[0].to_dict() + + async def test_get_user_chat_boosts(self, monkeypatch, bot): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + data = request_data.json_parameters + chat_id = data["chat_id"] == "3" + user_id = data["user_id"] == "2" + if not all((chat_id, user_id)): + pytest.fail("I got wrong parameters in post") + return data + + monkeypatch.setattr(bot.request, "post", make_assertion) + + assert await bot.get_user_chat_boosts("3", 2) + + +class TestUserChatBoostsWithRequest(ChatBoostDefaults): + async def test_get_user_chat_boosts(self, bot, channel_id, chat_id): + chat_boosts = await bot.get_user_chat_boosts(channel_id, chat_id) + assert isinstance(chat_boosts, UserChatBoosts) diff --git a/tests/test_constants.py b/tests/test_constants.py index 681de2b97f0..8f75f3dfe4c 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -17,12 +17,18 @@ # 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 asyncio +import inspect import json +import re -from telegram import constants +import pytest + +from telegram import Message, constants from telegram._utils.enum import IntEnum, StringEnum from telegram.error import BadRequest +from tests.auxil.build_messages import make_message from tests.auxil.files import data_file +from tests.auxil.string_manipulation import to_snake_case class StrEnumTest(StringEnum): @@ -47,7 +53,7 @@ def test__all__(self): not key.startswith("_") # exclude imported stuff and getattr(member, "__module__", "telegram.constants") == "telegram.constants" - and key != "sys" + and key not in ("sys", "datetime") ) } actual = set(constants.__all__) @@ -128,6 +134,96 @@ def test_bot_api_version_info(self): assert vi[0] == vi.major assert vi[1] == vi.minor + @staticmethod + def is_type_attribute(name: str) -> bool: + # Return False if the attribute doesn't generate a message type, i.e. only message + # metadata. Manually excluding a lot of attributes here is a bit of work, but it makes + # sure that we don't miss any new message types in the future. + patters = { + "(text|caption)_(markdown|html)", + "caption_(entities|html|markdown)", + "(edit_)?date", + "forward_", + "has_", + } + + if any(re.match(pattern, name) for pattern in patters): + return False + if name in { + "author_signature", + "api_kwargs", + "caption", + "chat", + "chat_id", + "effective_attachment", + "entities", + "from_user", + "id", + "is_automatic_forward", + "is_topic_message", + "link", + "link_preview_options", + "media_group_id", + "message_id", + "message_thread_id", + "migrate_from_chat_id", + "reply_markup", + "reply_to_message", + "sender_chat", + "is_accessible", + "quote", + "external_reply", + # attribute is deprecated, no need to add it to MessageType + "user_shared", + "via_bot", + }: + return False + + return True + + @pytest.mark.parametrize( + "attribute", + [ + name + for name, _ in inspect.getmembers( + make_message("test"), lambda x: not inspect.isroutine(x) + ) + ], + ) + def test_message_type_completeness(self, attribute): + if attribute.startswith("_") or not self.is_type_attribute(attribute): + return + + assert hasattr(constants.MessageType, attribute.upper()), ( + f"Missing MessageType.{attribute}. Please also check if this should be present in " + f"MessageAttachmentType." + ) + + @pytest.mark.parametrize("member", constants.MessageType) + def test_message_type_completeness_reverse(self, member): + assert self.is_type_attribute( + member.value + ), f"Additional member {member} in MessageType that should not be a message type" + + @pytest.mark.parametrize("member", constants.MessageAttachmentType) + def test_message_attachment_type_completeness(self, member): + try: + constants.MessageType(member) + except ValueError: + pytest.fail(f"Missing MessageType for {member}") + + def test_message_attachment_type_completeness_reverse(self): + # Getting the type hints of a property is a bit tricky, so we instead parse the docstring + # for now + for match in re.finditer(r"`telegram.(\w+)`", Message.effective_attachment.__doc__): + name = to_snake_case(match.group(1)) + if name == "photo_size": + name = "photo" + try: + constants.MessageAttachmentType(name) + except ValueError: + pytest.fail(f"Missing MessageAttachmentType for {match.group(1)}") + class TestConstantsWithRequest: async def test_max_message_length(self, bot, chat_id): diff --git a/tests/test_enum_types.py b/tests/test_enum_types.py index 15744bea31d..2a236f37324 100644 --- a/tests/test_enum_types.py +++ b/tests/test_enum_types.py @@ -27,6 +27,8 @@ / "_passport", } +exclude_patterns = {re.compile(re.escape("self.type: ReactionType = type"))} + def test_types_are_converted_to_enum(): """We want to convert all attributes of name "type" to an enum from telegram.constants. @@ -43,6 +45,9 @@ def test_types_are_converted_to_enum(): text = path.read_text(encoding="utf-8") for match in re.finditer(pattern, text): + if any(exclude_pattern.match(match.group(0)) for exclude_pattern in exclude_patterns): + continue + assert match.group(1).startswith("enum.get_member") or match.group(1).startswith( "get_member" ), ( diff --git a/tests/test_giveaway.py b/tests/test_giveaway.py new file mode 100644 index 00000000000..611e098a6c8 --- /dev/null +++ b/tests/test_giveaway.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2023 +# 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 datetime as dtm + +import pytest + +from telegram import ( + BotCommand, + Chat, + Giveaway, + GiveawayCompleted, + GiveawayCreated, + GiveawayWinners, + Message, + User, +) +from telegram._utils.datetime import UTC, to_timestamp +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def giveaway(): + return Giveaway( + chats=[Chat(1, Chat.CHANNEL), Chat(2, Chat.SUPERGROUP)], + winners_selection_date=TestGiveawayWithoutRequest.winners_selection_date, + winner_count=TestGiveawayWithoutRequest.winner_count, + only_new_members=TestGiveawayWithoutRequest.only_new_members, + has_public_winners=TestGiveawayWithoutRequest.has_public_winners, + prize_description=TestGiveawayWithoutRequest.prize_description, + country_codes=TestGiveawayWithoutRequest.country_codes, + premium_subscription_month_count=( + TestGiveawayWithoutRequest.premium_subscription_month_count + ), + ) + + +class TestGiveawayWithoutRequest: + chats = [Chat(1, Chat.CHANNEL), Chat(2, Chat.SUPERGROUP)] + winners_selection_date = dtm.datetime.now(dtm.timezone.utc).replace(microsecond=0) + winner_count = 42 + only_new_members = True + has_public_winners = True + prize_description = "prize_description" + country_codes = ["DE", "US"] + premium_subscription_month_count = 3 + + def test_slot_behaviour(self, giveaway): + for attr in giveaway.__slots__: + assert getattr(giveaway, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(giveaway)) == len(set(mro_slots(giveaway))), "duplicate slot" + + def test_de_json(self, bot): + json_dict = { + "chats": [chat.to_dict() for chat in self.chats], + "winners_selection_date": to_timestamp(self.winners_selection_date), + "winner_count": self.winner_count, + "only_new_members": self.only_new_members, + "has_public_winners": self.has_public_winners, + "prize_description": self.prize_description, + "country_codes": self.country_codes, + "premium_subscription_month_count": self.premium_subscription_month_count, + } + + giveaway = Giveaway.de_json(json_dict, bot) + assert giveaway.api_kwargs == {} + + assert giveaway.chats == tuple(self.chats) + assert giveaway.winners_selection_date == self.winners_selection_date + assert giveaway.winner_count == self.winner_count + assert giveaway.only_new_members == self.only_new_members + assert giveaway.has_public_winners == self.has_public_winners + assert giveaway.prize_description == self.prize_description + assert giveaway.country_codes == tuple(self.country_codes) + assert giveaway.premium_subscription_month_count == self.premium_subscription_month_count + + assert Giveaway.de_json(None, bot) is None + + def test_de_json_localization(self, tz_bot, bot, raw_bot): + json_dict = { + "chats": [chat.to_dict() for chat in self.chats], + "winners_selection_date": to_timestamp(self.winners_selection_date), + "winner_count": self.winner_count, + "only_new_members": self.only_new_members, + "has_public_winners": self.has_public_winners, + "prize_description": self.prize_description, + "country_codes": self.country_codes, + "premium_subscription_month_count": self.premium_subscription_month_count, + } + + giveaway_raw = Giveaway.de_json(json_dict, raw_bot) + giveaway_bot = Giveaway.de_json(json_dict, bot) + giveaway_bot_tz = Giveaway.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + giveaway_bot_tz_offset = giveaway_bot_tz.winners_selection_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset( + giveaway_bot_tz.winners_selection_date.replace(tzinfo=None) + ) + + assert giveaway_raw.winners_selection_date.tzinfo == UTC + assert giveaway_bot.winners_selection_date.tzinfo == UTC + assert giveaway_bot_tz_offset == tz_bot_offset + + def test_to_dict(self, giveaway): + giveaway_dict = giveaway.to_dict() + + assert isinstance(giveaway_dict, dict) + assert giveaway_dict["chats"] == [chat.to_dict() for chat in self.chats] + assert giveaway_dict["winners_selection_date"] == to_timestamp(self.winners_selection_date) + assert giveaway_dict["winner_count"] == self.winner_count + assert giveaway_dict["only_new_members"] == self.only_new_members + assert giveaway_dict["has_public_winners"] == self.has_public_winners + assert giveaway_dict["prize_description"] == self.prize_description + assert giveaway_dict["country_codes"] == self.country_codes + assert ( + giveaway_dict["premium_subscription_month_count"] + == self.premium_subscription_month_count + ) + + def test_equality(self, giveaway): + a = giveaway + b = Giveaway( + chats=self.chats, + winners_selection_date=self.winners_selection_date, + winner_count=self.winner_count, + ) + c = Giveaway( + chats=self.chats, + winners_selection_date=self.winners_selection_date + dtm.timedelta(seconds=100), + winner_count=self.winner_count, + ) + d = Giveaway( + chats=self.chats, winners_selection_date=self.winners_selection_date, winner_count=17 + ) + e = BotCommand("start", "description") + + 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) + + +class TestGiveawayCreatedWithoutRequest: + def test_slot_behaviour(self): + giveaway_created = GiveawayCreated() + for attr in giveaway_created.__slots__: + assert getattr(giveaway_created, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(giveaway_created)) == len( + set(mro_slots(giveaway_created)) + ), "duplicate slot" + + +@pytest.fixture(scope="module") +def giveaway_winners(): + return GiveawayWinners( + chat=TestGiveawayWinnersWithoutRequest.chat, + giveaway_message_id=TestGiveawayWinnersWithoutRequest.giveaway_message_id, + winners_selection_date=TestGiveawayWinnersWithoutRequest.winners_selection_date, + winner_count=TestGiveawayWinnersWithoutRequest.winner_count, + winners=TestGiveawayWinnersWithoutRequest.winners, + only_new_members=TestGiveawayWinnersWithoutRequest.only_new_members, + prize_description=TestGiveawayWinnersWithoutRequest.prize_description, + premium_subscription_month_count=( + TestGiveawayWinnersWithoutRequest.premium_subscription_month_count + ), + additional_chat_count=TestGiveawayWinnersWithoutRequest.additional_chat_count, + unclaimed_prize_count=TestGiveawayWinnersWithoutRequest.unclaimed_prize_count, + was_refunded=TestGiveawayWinnersWithoutRequest.was_refunded, + ) + + +class TestGiveawayWinnersWithoutRequest: + chat = Chat(1, Chat.CHANNEL) + giveaway_message_id = 123456789 + winners_selection_date = dtm.datetime.now(dtm.timezone.utc).replace(microsecond=0) + winner_count = 42 + winners = [User(1, "user1", False), User(2, "user2", False)] + additional_chat_count = 2 + premium_subscription_month_count = 3 + unclaimed_prize_count = 4 + only_new_members = True + was_refunded = True + prize_description = "prize_description" + + def test_slot_behaviour(self, giveaway_winners): + for attr in giveaway_winners.__slots__: + assert getattr(giveaway_winners, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(giveaway_winners)) == len( + set(mro_slots(giveaway_winners)) + ), "duplicate slot" + + def test_de_json(self, bot): + json_dict = { + "chat": self.chat.to_dict(), + "giveaway_message_id": self.giveaway_message_id, + "winners_selection_date": to_timestamp(self.winners_selection_date), + "winner_count": self.winner_count, + "winners": [winner.to_dict() for winner in self.winners], + "additional_chat_count": self.additional_chat_count, + "premium_subscription_month_count": self.premium_subscription_month_count, + "unclaimed_prize_count": self.unclaimed_prize_count, + "only_new_members": self.only_new_members, + "was_refunded": self.was_refunded, + "prize_description": self.prize_description, + } + + giveaway_winners = GiveawayWinners.de_json(json_dict, bot) + assert giveaway_winners.api_kwargs == {} + + assert giveaway_winners.chat == self.chat + assert giveaway_winners.giveaway_message_id == self.giveaway_message_id + assert giveaway_winners.winners_selection_date == self.winners_selection_date + assert giveaway_winners.winner_count == self.winner_count + assert giveaway_winners.winners == tuple(self.winners) + assert giveaway_winners.additional_chat_count == self.additional_chat_count + assert ( + giveaway_winners.premium_subscription_month_count + == self.premium_subscription_month_count + ) + assert giveaway_winners.unclaimed_prize_count == self.unclaimed_prize_count + assert giveaway_winners.only_new_members == self.only_new_members + assert giveaway_winners.was_refunded == self.was_refunded + assert giveaway_winners.prize_description == self.prize_description + + assert GiveawayWinners.de_json(None, bot) is None + + def test_de_json_localization(self, tz_bot, bot, raw_bot): + json_dict = { + "chat": self.chat.to_dict(), + "giveaway_message_id": self.giveaway_message_id, + "winners_selection_date": to_timestamp(self.winners_selection_date), + "winner_count": self.winner_count, + "winners": [winner.to_dict() for winner in self.winners], + } + + giveaway_winners_raw = GiveawayWinners.de_json(json_dict, raw_bot) + giveaway_winners_bot = GiveawayWinners.de_json(json_dict, bot) + giveaway_winners_bot_tz = GiveawayWinners.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + giveaway_winners_bot_tz_offset = giveaway_winners_bot_tz.winners_selection_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset( + giveaway_winners_bot_tz.winners_selection_date.replace(tzinfo=None) + ) + + assert giveaway_winners_raw.winners_selection_date.tzinfo == UTC + assert giveaway_winners_bot.winners_selection_date.tzinfo == UTC + assert giveaway_winners_bot_tz_offset == tz_bot_offset + + def test_to_dict(self, giveaway_winners): + giveaway_winners_dict = giveaway_winners.to_dict() + + assert isinstance(giveaway_winners_dict, dict) + assert giveaway_winners_dict["chat"] == self.chat.to_dict() + assert giveaway_winners_dict["giveaway_message_id"] == self.giveaway_message_id + assert giveaway_winners_dict["winners_selection_date"] == to_timestamp( + self.winners_selection_date + ) + assert giveaway_winners_dict["winner_count"] == self.winner_count + assert giveaway_winners_dict["winners"] == [winner.to_dict() for winner in self.winners] + assert giveaway_winners_dict["additional_chat_count"] == self.additional_chat_count + assert ( + giveaway_winners_dict["premium_subscription_month_count"] + == self.premium_subscription_month_count + ) + assert giveaway_winners_dict["unclaimed_prize_count"] == self.unclaimed_prize_count + assert giveaway_winners_dict["only_new_members"] == self.only_new_members + assert giveaway_winners_dict["was_refunded"] == self.was_refunded + assert giveaway_winners_dict["prize_description"] == self.prize_description + + def test_equality(self, giveaway_winners): + a = giveaway_winners + b = GiveawayWinners( + chat=self.chat, + giveaway_message_id=self.giveaway_message_id, + winners_selection_date=self.winners_selection_date, + winner_count=self.winner_count, + winners=self.winners, + ) + c = GiveawayWinners( + chat=self.chat, + giveaway_message_id=self.giveaway_message_id, + winners_selection_date=self.winners_selection_date + dtm.timedelta(seconds=100), + winner_count=self.winner_count, + winners=self.winners, + ) + d = GiveawayWinners( + chat=self.chat, + giveaway_message_id=self.giveaway_message_id, + winners_selection_date=self.winners_selection_date, + winner_count=17, + winners=self.winners, + ) + e = BotCommand("start", "description") + + 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) + + +@pytest.fixture(scope="module") +def giveaway_completed(): + return GiveawayCompleted( + winner_count=TestGiveawayCompletedWithoutRequest.winner_count, + unclaimed_prize_count=TestGiveawayCompletedWithoutRequest.unclaimed_prize_count, + giveaway_message=TestGiveawayCompletedWithoutRequest.giveaway_message, + ) + + +class TestGiveawayCompletedWithoutRequest: + winner_count = 42 + unclaimed_prize_count = 4 + giveaway_message = Message( + message_id=1, + date=dtm.datetime.now(dtm.timezone.utc), + text="giveaway_message", + chat=Chat(1, Chat.CHANNEL), + from_user=User(1, "user1", False), + ) + + def test_slot_behaviour(self, giveaway_completed): + for attr in giveaway_completed.__slots__: + assert getattr(giveaway_completed, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(giveaway_completed)) == len( + set(mro_slots(giveaway_completed)) + ), "duplicate slot" + + def test_de_json(self, bot): + json_dict = { + "winner_count": self.winner_count, + "unclaimed_prize_count": self.unclaimed_prize_count, + "giveaway_message": self.giveaway_message.to_dict(), + } + + giveaway_completed = GiveawayCompleted.de_json(json_dict, bot) + assert giveaway_completed.api_kwargs == {} + + assert giveaway_completed.winner_count == self.winner_count + assert giveaway_completed.unclaimed_prize_count == self.unclaimed_prize_count + assert giveaway_completed.giveaway_message == self.giveaway_message + + assert GiveawayCompleted.de_json(None, bot) is None + + def test_to_dict(self, giveaway_completed): + giveaway_completed_dict = giveaway_completed.to_dict() + + assert isinstance(giveaway_completed_dict, dict) + assert giveaway_completed_dict["winner_count"] == self.winner_count + assert giveaway_completed_dict["unclaimed_prize_count"] == self.unclaimed_prize_count + assert giveaway_completed_dict["giveaway_message"] == self.giveaway_message.to_dict() + + def test_equality(self, giveaway_completed): + a = giveaway_completed + b = GiveawayCompleted( + winner_count=self.winner_count, + unclaimed_prize_count=self.unclaimed_prize_count, + giveaway_message=self.giveaway_message, + ) + c = GiveawayCompleted( + winner_count=self.winner_count + 30, + unclaimed_prize_count=self.unclaimed_prize_count, + ) + d = GiveawayCompleted( + winner_count=self.winner_count, + unclaimed_prize_count=17, + giveaway_message=self.giveaway_message, + ) + e = GiveawayCompleted( + winner_count=self.winner_count + 1, + unclaimed_prize_count=self.unclaimed_prize_count, + ) + f = BotCommand("start", "description") + + 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) + + assert a != f + assert hash(a) != hash(f) diff --git a/tests/test_keyboardbutton.py b/tests/test_keyboardbutton.py index a18ca4ccd5f..4709ee18cf3 100644 --- a/tests/test_keyboardbutton.py +++ b/tests/test_keyboardbutton.py @@ -24,8 +24,10 @@ KeyboardButtonPollType, KeyboardButtonRequestChat, KeyboardButtonRequestUser, + KeyboardButtonRequestUsers, WebAppInfo, ) +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -38,7 +40,7 @@ def keyboard_button(): request_poll=TestKeyboardButtonBase.request_poll, web_app=TestKeyboardButtonBase.web_app, request_chat=TestKeyboardButtonBase.request_chat, - request_user=TestKeyboardButtonBase.request_user, + request_users=TestKeyboardButtonBase.request_users, ) @@ -49,7 +51,7 @@ class TestKeyboardButtonBase: request_poll = KeyboardButtonPollType("quiz") web_app = WebAppInfo(url="https://example.com") request_chat = KeyboardButtonRequestChat(1, True) - request_user = KeyboardButtonRequestUser(2) + request_users = KeyboardButtonRequestUsers(2) class TestKeyboardButtonWithoutRequest(TestKeyboardButtonBase): @@ -66,7 +68,7 @@ def test_expected_values(self, keyboard_button): assert keyboard_button.request_poll == self.request_poll assert keyboard_button.web_app == self.web_app assert keyboard_button.request_chat == self.request_chat - assert keyboard_button.request_user == self.request_user + assert keyboard_button.request_users == self.request_users def test_to_dict(self, keyboard_button): keyboard_button_dict = keyboard_button.to_dict() @@ -78,7 +80,7 @@ def test_to_dict(self, keyboard_button): assert keyboard_button_dict["request_poll"] == keyboard_button.request_poll.to_dict() assert keyboard_button_dict["web_app"] == keyboard_button.web_app.to_dict() assert keyboard_button_dict["request_chat"] == keyboard_button.request_chat.to_dict() - assert keyboard_button_dict["request_user"] == keyboard_button.request_user.to_dict() + assert keyboard_button_dict["request_users"] == keyboard_button.request_users.to_dict() def test_de_json(self, bot): json_dict = { @@ -88,7 +90,7 @@ def test_de_json(self, bot): "request_poll": self.request_poll.to_dict(), "web_app": self.web_app.to_dict(), "request_chat": self.request_chat.to_dict(), - "request_user": self.request_user.to_dict(), + "request_users": self.request_users.to_dict(), } inline_keyboard_button = KeyboardButton.de_json(json_dict, None) @@ -99,11 +101,36 @@ def test_de_json(self, bot): assert inline_keyboard_button.request_poll == self.request_poll assert inline_keyboard_button.web_app == self.web_app assert inline_keyboard_button.request_chat == self.request_chat - assert inline_keyboard_button.request_user == self.request_user + assert inline_keyboard_button.request_user == self.request_users + assert inline_keyboard_button.request_users == self.request_users none = KeyboardButton.de_json({}, None) assert none is None + def test_request_user_deprecation_mutually_exclusive(self): + with pytest.raises(ValueError, match="'request_user' was renamed to 'request_users'"): + KeyboardButton( + "test", + request_users=KeyboardButtonRequestUsers(1), + request_user=KeyboardButtonRequestUsers(2), + ) + + def test_request_user_argument_deprecation_warning(self): + with pytest.warns( + PTBDeprecationWarning, match="'request_user' to 'request_users'" + ) as record: + KeyboardButton("test", request_user=KeyboardButtonRequestUser(2)) + + assert record[0].filename == __file__, "wrong stacklevel" + + def test_request_user_property_deprecation_warning(self, keyboard_button): + with pytest.warns( + PTBDeprecationWarning, match="'request_user' to 'request_users'" + ) as record: + assert keyboard_button.request_user is keyboard_button.request_users + + assert record[0].filename == __file__, "wrong stacklevel" + def test_equality(self): a = KeyboardButton("test", request_contact=True) b = KeyboardButton("test", request_contact=True) @@ -114,13 +141,13 @@ def test_equality(self): "test", request_contact=True, request_chat=KeyboardButtonRequestChat(1, False), - request_user=KeyboardButtonRequestUser(2), + request_users=KeyboardButtonRequestUsers(2), ) g = KeyboardButton( "test", request_contact=True, request_chat=KeyboardButtonRequestChat(1, False), - request_user=KeyboardButtonRequestUser(2), + request_users=KeyboardButtonRequestUsers(2), ) assert a == b diff --git a/tests/test_keyboardbuttonrequest.py b/tests/test_keyboardbuttonrequest.py index 764fd376dd0..2a70df2406e 100644 --- a/tests/test_keyboardbuttonrequest.py +++ b/tests/test_keyboardbuttonrequest.py @@ -16,59 +16,73 @@ # # 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 inspect import pytest -from telegram import ChatAdministratorRights, KeyboardButtonRequestChat, KeyboardButtonRequestUser +from telegram import ( + ChatAdministratorRights, + KeyboardButtonRequestChat, + KeyboardButtonRequestUser, + KeyboardButtonRequestUsers, +) +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @pytest.fixture(scope="class") -def request_user(): - return KeyboardButtonRequestUser( - TestKeyboardButtonRequestUserBase.request_id, - TestKeyboardButtonRequestUserBase.user_is_bot, - TestKeyboardButtonRequestUserBase.user_is_premium, +def request_users(): + return KeyboardButtonRequestUsers( + TestKeyboardButtonRequestUsersBase.request_id, + TestKeyboardButtonRequestUsersBase.user_is_bot, + TestKeyboardButtonRequestUsersBase.user_is_premium, + TestKeyboardButtonRequestUsersBase.max_quantity, ) -class TestKeyboardButtonRequestUserBase: +class TestKeyboardButtonRequestUsersBase: request_id = 123 user_is_bot = True user_is_premium = False + max_quantity = 10 -class TestKeyboardButtonRequestUserWithoutRequest(TestKeyboardButtonRequestUserBase): - def test_slot_behaviour(self, request_user): - for attr in request_user.__slots__: - assert getattr(request_user, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(request_user)) == len(set(mro_slots(request_user))), "duplicate slot" +class TestKeyboardButtonRequestUsersWithoutRequest(TestKeyboardButtonRequestUsersBase): + def test_slot_behaviour(self, request_users): + for attr in request_users.__slots__: + assert getattr(request_users, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(request_users)) == len( + set(mro_slots(request_users)) + ), "duplicate slot" - def test_to_dict(self, request_user): - request_user_dict = request_user.to_dict() + def test_to_dict(self, request_users): + request_users_dict = request_users.to_dict() - assert isinstance(request_user_dict, dict) - assert request_user_dict["request_id"] == self.request_id - assert request_user_dict["user_is_bot"] == self.user_is_bot - assert request_user_dict["user_is_premium"] == self.user_is_premium + assert isinstance(request_users_dict, dict) + assert request_users_dict["request_id"] == self.request_id + assert request_users_dict["user_is_bot"] == self.user_is_bot + assert request_users_dict["user_is_premium"] == self.user_is_premium + assert request_users_dict["max_quantity"] == self.max_quantity def test_de_json(self, bot): json_dict = { "request_id": self.request_id, "user_is_bot": self.user_is_bot, "user_is_premium": self.user_is_premium, + "max_quantity": self.max_quantity, } - request_user = KeyboardButtonRequestUser.de_json(json_dict, bot) - assert request_user.api_kwargs == {} + request_users = KeyboardButtonRequestUsers.de_json(json_dict, bot) + assert request_users.api_kwargs == {} - assert request_user.request_id == self.request_id - assert request_user.user_is_bot == self.user_is_bot - assert request_user.user_is_premium == self.user_is_premium + assert request_users.request_id == self.request_id + assert request_users.user_is_bot == self.user_is_bot + assert request_users.user_is_premium == self.user_is_premium + assert request_users.max_quantity == self.max_quantity def test_equality(self): - a = KeyboardButtonRequestUser(self.request_id) - b = KeyboardButtonRequestUser(self.request_id) - c = KeyboardButtonRequestUser(1) + a = KeyboardButtonRequestUsers(self.request_id) + b = KeyboardButtonRequestUsers(self.request_id) + c = KeyboardButtonRequestUsers(1) assert a == b assert hash(a) == hash(b) @@ -78,6 +92,28 @@ def test_equality(self): assert hash(a) != hash(c) +class TestKeyboardButtonRequestUserWithoutRequest: + def test_slot_behaviour(self): + inst = KeyboardButtonRequestUser(1) + 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_signature(self): + assert inspect.signature(KeyboardButtonRequestUser) == inspect.signature( + KeyboardButtonRequestUsers + ) + + def test_deprecation_warning(self): + with pytest.warns( + PTBDeprecationWarning, + match="'KeyboardButtonRequestUser' was renamed to 'KeyboardButtonRequestUsers'", + ) as record: + KeyboardButtonRequestUser(request_id=1) + + assert record[0].filename == __file__, "wrong stacklevel" + + @pytest.fixture(scope="class") def request_chat(): return KeyboardButtonRequestChat( diff --git a/tests/test_linkpreviewoptions.py b/tests/test_linkpreviewoptions.py new file mode 100644 index 00000000000..7b5bc7beb8b --- /dev/null +++ b/tests/test_linkpreviewoptions.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2024 +# 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 LinkPreviewOptions +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def link_preview_options(): + return LinkPreviewOptions( + is_disabled=TestLinkPreviewOptionsBase.is_disabled, + url=TestLinkPreviewOptionsBase.url, + prefer_small_media=TestLinkPreviewOptionsBase.prefer_small_media, + prefer_large_media=TestLinkPreviewOptionsBase.prefer_large_media, + show_above_text=TestLinkPreviewOptionsBase.show_above_text, + ) + + +class TestLinkPreviewOptionsBase: + is_disabled = True + url = "https://www.example.com" + prefer_small_media = True + prefer_large_media = False + show_above_text = True + + +class TestLinkPreviewOptionsWithoutRequest(TestLinkPreviewOptionsBase): + def test_slot_behaviour(self, link_preview_options): + a = link_preview_options + for attr in a.__slots__: + assert getattr(a, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(a)) == len(set(mro_slots(a))), "duplicate slot" + + def test_to_dict(self, link_preview_options): + link_preview_options_dict = link_preview_options.to_dict() + + assert isinstance(link_preview_options_dict, dict) + assert link_preview_options_dict["is_disabled"] == self.is_disabled + assert link_preview_options_dict["url"] == self.url + assert link_preview_options_dict["prefer_small_media"] == self.prefer_small_media + assert link_preview_options_dict["prefer_large_media"] == self.prefer_large_media + assert link_preview_options_dict["show_above_text"] == self.show_above_text + + def test_de_json(self, link_preview_options): + link_preview_options_dict = { + "is_disabled": self.is_disabled, + "url": self.url, + "prefer_small_media": self.prefer_small_media, + "prefer_large_media": self.prefer_large_media, + "show_above_text": self.show_above_text, + } + + link_preview_options = LinkPreviewOptions.de_json(link_preview_options_dict, bot=None) + assert link_preview_options.api_kwargs == {} + + assert link_preview_options.is_disabled == self.is_disabled + assert link_preview_options.url == self.url + assert link_preview_options.prefer_small_media == self.prefer_small_media + assert link_preview_options.prefer_large_media == self.prefer_large_media + assert link_preview_options.show_above_text == self.show_above_text + + def test_equality(self): + a = LinkPreviewOptions( + self.is_disabled, + self.url, + self.prefer_small_media, + self.prefer_large_media, + self.show_above_text, + ) + b = LinkPreviewOptions( + self.is_disabled, + self.url, + self.prefer_small_media, + self.prefer_large_media, + self.show_above_text, + ) + c = LinkPreviewOptions(self.is_disabled) + d = LinkPreviewOptions( + False, self.url, self.prefer_small_media, self.prefer_large_media, self.show_above_text + ) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_maybeinaccessiblemessage.py b/tests/test_maybeinaccessiblemessage.py new file mode 100644 index 00000000000..0db8fc75ef3 --- /dev/null +++ b/tests/test_maybeinaccessiblemessage.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2023 +# 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 datetime as dtm + +import pytest + +from telegram import Chat, InaccessibleMessage, MaybeInaccessibleMessage +from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import ZERO_DATE +from telegram.warnings import PTBDeprecationWarning +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="class") +def maybe_inaccessible_message(): + return MaybeInaccessibleMessage( + TestMaybeInaccessibleMessageBase.chat, + TestMaybeInaccessibleMessageBase.message_id, + TestMaybeInaccessibleMessageBase.date, + ) + + +class TestMaybeInaccessibleMessageBase: + chat = Chat(1, "title") + message_id = 123 + date = dtm.datetime.now(dtm.timezone.utc).replace(microsecond=0) + + +class TestMaybeInaccessibleMessageWithoutRequest(TestMaybeInaccessibleMessageBase): + def test_slot_behaviour(self, maybe_inaccessible_message): + for attr in maybe_inaccessible_message.__slots__: + assert ( + getattr(maybe_inaccessible_message, attr, "err") != "err" + ), f"got extra slot '{attr}'" + assert len(mro_slots(maybe_inaccessible_message)) == len( + set(mro_slots(maybe_inaccessible_message)) + ), "duplicate slot" + + def test_to_dict(self, maybe_inaccessible_message): + maybe_inaccessible_message_dict = maybe_inaccessible_message.to_dict() + + assert isinstance(maybe_inaccessible_message_dict, dict) + assert maybe_inaccessible_message_dict["chat"] == self.chat.to_dict() + assert maybe_inaccessible_message_dict["message_id"] == self.message_id + assert maybe_inaccessible_message_dict["date"] == to_timestamp(self.date) + + def test_de_json(self, bot): + json_dict = { + "chat": self.chat.to_dict(), + "message_id": self.message_id, + "date": to_timestamp(self.date), + } + maybe_inaccessible_message = MaybeInaccessibleMessage.de_json(json_dict, bot) + assert maybe_inaccessible_message.api_kwargs == {} + + assert maybe_inaccessible_message.chat == self.chat + assert maybe_inaccessible_message.message_id == self.message_id + assert maybe_inaccessible_message.date == self.date + + def test_de_json_localization(self, tz_bot, bot, raw_bot): + json_dict = { + "chat": self.chat.to_dict(), + "message_id": self.message_id, + "date": to_timestamp(self.date), + } + + maybe_inaccessible_message_raw = MaybeInaccessibleMessage.de_json(json_dict, raw_bot) + maybe_inaccessible_message_bot = MaybeInaccessibleMessage.de_json(json_dict, bot) + maybe_inaccessible_message_bot_tz = MaybeInaccessibleMessage.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + maybe_inaccessible_message_bot_tz_offset = ( + maybe_inaccessible_message_bot_tz.date.utcoffset() + ) + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset( + maybe_inaccessible_message_bot_tz.date.replace(tzinfo=None) + ) + + assert maybe_inaccessible_message_raw.date.tzinfo == UTC + assert maybe_inaccessible_message_bot.date.tzinfo == UTC + assert maybe_inaccessible_message_bot_tz_offset == tz_bot_offset + + def test_de_json_zero_date(self, bot): + json_dict = { + "chat": self.chat.to_dict(), + "message_id": self.message_id, + "date": 0, + } + + maybe_inaccessible_message = MaybeInaccessibleMessage.de_json(json_dict, bot) + assert maybe_inaccessible_message.date == ZERO_DATE + assert maybe_inaccessible_message.date is ZERO_DATE + + def test_is_accessible(self): + assert MaybeInaccessibleMessage(self.chat, self.message_id, self.date).is_accessible + assert not MaybeInaccessibleMessage(self.chat, self.message_id, ZERO_DATE).is_accessible + + def test_bool(self): + assert bool(MaybeInaccessibleMessage(self.chat, self.message_id, self.date).is_accessible) + assert not bool( + MaybeInaccessibleMessage(self.chat, self.message_id, ZERO_DATE).is_accessible + ) + + @pytest.mark.parametrize("cls", [MaybeInaccessibleMessage, InaccessibleMessage]) + def test_bool_deprecation_warning(self, cls): + if cls is MaybeInaccessibleMessage: + args = (self.chat, self.message_id, self.date) + else: + args = (self.chat, self.message_id) + + with pytest.warns( + PTBDeprecationWarning, + match=( + f"`{cls.__name__}.__bool__` will be reverted to Pythons default implementation" + ), + ) as record: + bool(cls(*args)) + + assert record[0].filename == __file__, "wrong stacklevel" + + def test_equality(self, maybe_inaccessible_message): + a = maybe_inaccessible_message + b = MaybeInaccessibleMessage( + self.chat, self.message_id, self.date + dtm.timedelta(seconds=1) + ) + c = MaybeInaccessibleMessage(self.chat, self.message_id + 1, self.date) + d = MaybeInaccessibleMessage(Chat(2, "title"), self.message_id, self.date) + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a != c + assert hash(a) != hash(c) + assert a is not c + + assert a != d + assert hash(a) != hash(d) + assert a is not d diff --git a/tests/test_message.py b/tests/test_message.py index 9fbc36bc801..ae9e79b69c5 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -30,23 +30,32 @@ Contact, Dice, Document, + ExternalReplyInfo, Game, + Giveaway, + GiveawayCompleted, + GiveawayCreated, + GiveawayWinners, Invoice, + LinkPreviewOptions, Location, Message, MessageAutoDeleteTimerChanged, MessageEntity, + MessageOriginChat, PassportData, PhotoSize, Poll, PollOption, ProximityAlertTriggered, + ReplyParameters, Sticker, Story, SuccessfulPayment, + TextQuote, Update, User, - UserShared, + UsersShared, Venue, Video, VideoChatEnded, @@ -60,18 +69,21 @@ from telegram._utils.datetime import UTC from telegram.constants import ChatAction, ParseMode from telegram.ext import Defaults +from telegram.warnings import PTBDeprecationWarning from tests._passport.test_passport import RAW_PASSPORT_DATA from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, check_shortcut_signature, ) +from tests.auxil.build_messages import make_message +from tests.auxil.pytest_classes import PytestExtBot, PytestMessage from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") def message(bot): - message = Message( + message = PytestMessage( message_id=TestMessageBase.id_, date=TestMessageBase.date, chat=copy(TestMessageBase.chat), @@ -212,8 +224,51 @@ def message(bot): }, {"web_app_data": WebAppData("some_data", "some_button_text")}, {"message_thread_id": 123}, - {"user_shared": UserShared(1, 2)}, + # Using a `UserShared` object here doesn't work, because `to_dict` produces `user_ids` + # instead of `user_id` - but that's what we want to test here. + {"user_shared": {"request_id": 1, "user_id": 2}}, + {"users_shared": UsersShared(1, [2, 3])}, {"chat_shared": ChatShared(3, 4)}, + { + "giveaway": Giveaway( + chats=[Chat(1, Chat.SUPERGROUP)], + winners_selection_date=datetime.utcnow().replace(microsecond=0), + winner_count=5, + ) + }, + {"giveaway_created": GiveawayCreated()}, + { + "giveaway_winners": GiveawayWinners( + chat=Chat(1, Chat.CHANNEL), + giveaway_message_id=123456789, + winners_selection_date=datetime.utcnow().replace(microsecond=0), + winner_count=42, + winners=[User(1, "user1", False), User(2, "user2", False)], + ) + }, + { + "giveaway_completed": GiveawayCompleted( + winner_count=42, + unclaimed_prize_count=4, + giveaway_message=make_message(text="giveaway_message"), + ) + }, + { + "link_preview_options": LinkPreviewOptions( + is_disabled=True, + url="https://python-telegram-bot.org", + prefer_small_media=True, + prefer_large_media=True, + show_above_text=True, + ) + }, + { + "external_reply": ExternalReplyInfo( + MessageOriginChat(datetime.utcnow(), Chat(1, Chat.PRIVATE)) + ) + }, + {"quote": TextQuote("a text quote", 1)}, + {"forward_origin": MessageOriginChat(datetime.utcnow(), Chat(1, Chat.PRIVATE))}, ], ids=[ "forwarded_user", @@ -270,7 +325,16 @@ def message(bot): "web_app_data", "message_thread_id", "user_shared", + "users_shared", "chat_shared", + "giveaway", + "giveaway_created", + "giveaway_winners", + "giveaway_completed", + "link_preview_options", + "external_reply", + "quote", + "forward_origin", ], ) def message_params(bot, request): @@ -326,10 +390,13 @@ class TestMessageBase: {"length": 10, "offset": 129, "type": "pre", "language": "python"}, {"length": 7, "offset": 141, "type": "spoiler"}, {"length": 2, "offset": 150, "type": "custom_emoji", "custom_emoji_id": "1"}, + {"length": 34, "offset": 154, "type": "blockquote"}, + {"length": 6, "offset": 181, "type": "bold"}, ] test_text_v2 = ( r"Test for trgh nested in italic. Python pre. Spoiled. 👍." + "http://google.com and bold nested in strk>trgh nested in italic. Python pre. Spoiled. " + "👍.\nMultiline\nblock quote\nwith nested." ) test_message = Message( message_id=1, @@ -354,7 +421,81 @@ class TestMessageBase: class TestMessageWithoutRequest(TestMessageBase): - def test_slot_behaviour(self, message): + @staticmethod + async def check_quote_parsing( + message: Message, method, bot_method_name: str, args, monkeypatch + ): + """Used in testing reply_* below. Makes sure that quote and do_quote are handled + correctly + """ + with pytest.raises(ValueError, match="`quote` and `do_quote` are mutually exclusive"): + await method(*args, quote=True, do_quote=True) + + with pytest.warns(PTBDeprecationWarning, match="`quote` parameter is deprecated"): + await method(*args, quote=True) + + with pytest.raises( + ValueError, + match="`reply_to_message_id` and `reply_parameters` are mutually exclusive.", + ): + await method(*args, reply_to_message_id=42, reply_parameters=42) + + async def make_assertion(*args, **kwargs): + return kwargs.get("chat_id"), kwargs.get("reply_parameters") + + monkeypatch.setattr(message.get_bot(), bot_method_name, make_assertion) + + for param in ("quote", "do_quote"): + chat_id, reply_parameters = await method(*args, **{param: True}) + if chat_id != message.chat.id: + pytest.fail(f"chat_id is {chat_id} but should be {message.chat.id}") + if reply_parameters is None or reply_parameters.message_id != message.message_id: + pytest.fail( + f"reply_parameters is {reply_parameters} but should be {message.message_id}" + ) + + input_chat_id = object() + input_reply_parameters = ReplyParameters(message_id=1, chat_id=42) + chat_id, reply_parameters = await method( + *args, do_quote={"chat_id": input_chat_id, "reply_parameters": input_reply_parameters} + ) + if chat_id is not input_chat_id: + pytest.fail(f"chat_id is {chat_id} but should be {chat_id}") + if reply_parameters is not input_reply_parameters: + pytest.fail(f"reply_parameters is {reply_parameters} but should be {reply_parameters}") + + input_parameters_2 = ReplyParameters(message_id=2, chat_id=43) + chat_id, reply_parameters = await method( + *args, + reply_parameters=input_parameters_2, + # passing these here to make sure that `reply_parameters` has higher priority + do_quote={"chat_id": input_chat_id, "reply_parameters": input_reply_parameters}, + ) + if chat_id is not message.chat.id: + pytest.fail(f"chat_id is {chat_id} but should be {message.chat.id}") + if reply_parameters is not input_parameters_2: + pytest.fail( + f"reply_parameters is {reply_parameters} but should be {input_parameters_2}" + ) + + chat_id, reply_parameters = await method( + *args, + reply_to_message_id=42, + # passing these here to make sure that `reply_to_message_id` has higher priority + do_quote={"chat_id": input_chat_id, "reply_parameters": input_reply_parameters}, + ) + if chat_id != message.chat.id: + pytest.fail(f"chat_id is {chat_id} but should be {message.chat.id}") + if reply_parameters is None or reply_parameters.message_id != 42: + pytest.fail(f"reply_parameters is {reply_parameters} but should be 42") + + def test_slot_behaviour(self): + message = Message( + message_id=TestMessageBase.id_, + date=TestMessageBase.date, + chat=copy(TestMessageBase.chat), + from_user=copy(TestMessageBase.from_user), + ) for attr in message.__slots__: assert getattr(message, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(message)) == len(set(mro_slots(message))), "duplicate slot" @@ -431,6 +572,130 @@ def test_equality(self): assert a != e assert hash(a) != hash(e) + def test_user_shared_init_deprecation(self, message): + with pytest.warns( + PTBDeprecationWarning, match="'user_shared' was renamed to 'users_shared'" + ) as record: + Message(message_id=1, date=self.date, chat=self.chat, user_shared=1) + + assert record[0].filename == __file__, "wrong stacklevel" + + def test_user_shared_property_deprecation(self, message): + with pytest.warns( + PTBDeprecationWarning, match="'user_shared' to 'users_shared'" + ) as record: + message.user_shared + + assert record[0].filename == __file__, "wrong stacklevel" + + def test_forward_from_init_deprecation(self, message): + with pytest.warns( + PTBDeprecationWarning, match="'forward_from' was transferred to 'forward_origin'" + ) as record: + Message( + message_id=1, date=self.date, chat=self.chat, forward_from=User(1, "user", False) + ) + + assert record[0].filename == __file__, "wrong stacklevel" + + def test_forward_from_property_deprecation(self, message): + with pytest.warns( + PTBDeprecationWarning, match="'forward_from' to 'forward_origin'" + ) as record: + message.forward_from + + assert record[0].filename == __file__, "wrong stacklevel" + + def test_forward_from_chat_init_deprecation(self, message): + with pytest.warns( + PTBDeprecationWarning, match="'forward_from_chat' was transferred to 'forward_origin'" + ) as record: + Message( + message_id=1, date=self.date, chat=self.chat, forward_from_chat=Chat(1, "private") + ) + + assert record[0].filename == __file__, "wrong stacklevel" + + def test_forward_from_chat_property_deprecation(self, message): + with pytest.warns( + PTBDeprecationWarning, match="'forward_from_chat' to 'forward_origin'" + ) as record: + message.forward_from_chat + + assert record[0].filename == __file__, "wrong stacklevel" + + def test_forward_from_message_id_init_deprecation(self, message): + with pytest.warns( + PTBDeprecationWarning, + match="'forward_from_message_id' was transferred to 'forward_origin'", + ) as record: + Message(message_id=1, date=self.date, chat=self.chat, forward_from_message_id=1) + + assert record[0].filename == __file__, "wrong stacklevel" + + def test_forward_from_message_id_property_deprecation(self, message): + with pytest.warns( + PTBDeprecationWarning, match="'forward_from_message_id' to 'forward_origin'" + ) as record: + message.forward_from_message_id + + assert record[0].filename == __file__, "wrong stacklevel" + + def test_forward_signature_init_deprecation(self, message): + with pytest.warns( + PTBDeprecationWarning, match="'forward_signature' was transferred to 'forward_origin'" + ) as record: + Message(message_id=1, date=self.date, chat=self.chat, forward_signature="signature") + + assert record[0].filename == __file__, "wrong stacklevel" + + def test_forward_signature_property_deprecation(self, message): + with pytest.warns( + PTBDeprecationWarning, match="'forward_signature' to 'forward_origin'" + ) as record: + message.forward_signature + + assert record[0].filename == __file__, "wrong stacklevel" + + def test_forward_sender_name_init_deprecation(self, message): + with pytest.warns( + PTBDeprecationWarning, + match="'forward_sender_name' was transferred to 'forward_origin'", + ) as record: + Message(message_id=1, date=self.date, chat=self.chat, forward_sender_name="name") + + assert record[0].filename == __file__, "wrong stacklevel" + + def test_forward_sender_name_property_deprecation(self, message): + with pytest.warns( + PTBDeprecationWarning, match="'forward_sender_name' to 'forward_origin'" + ) as record: + message.forward_sender_name + + assert record[0].filename == __file__, "wrong stacklevel" + + def test_forward_date_init_deprecation(self, message): + with pytest.warns( + PTBDeprecationWarning, match="'forward_date' was transferred to 'forward_origin'" + ) as record: + Message(message_id=1, date=self.date, chat=self.chat, forward_date=datetime.utcnow()) + + assert record[0].filename == __file__, "wrong stacklevel" + + def test_forward_date_property_deprecation(self, message): + with pytest.warns( + PTBDeprecationWarning, match="'forward_date' to 'forward_origin'" + ) as record: + message.forward_date + + assert record[0].filename == __file__, "wrong stacklevel" + + def test_bool(self, message, recwarn): + # Relevant as long as we override MaybeInaccessibleMessage.__bool__ + # Can be removed once that's removed + assert bool(message) is True + assert len(recwarn) == 0 + async def test_parse_entity(self): text = ( b"\\U0001f469\\u200d\\U0001f469\\u200d\\U0001f467" @@ -518,7 +783,8 @@ def test_text_html_simple(self): "and bold nested in strk>trgh nested in italic. " '
Python pre
. ' 'Spoiled. ' - '👍.' + '👍.\n' + "
Multiline\nblock quote\nwith nested.
" ) text_html = self.test_message_v2.text_html assert text_html == test_html_string @@ -538,7 +804,8 @@ def test_text_html_urled(self): "and bold nested in strk>trgh nested in italic. " '
Python pre
. ' 'Spoiled. ' - '👍.' + '👍.\n' + "
Multiline\nblock quote\nwith nested.
" ) text_html = self.test_message_v2.text_html_urled assert text_html == test_html_string @@ -559,12 +826,25 @@ def test_text_markdown_v2_simple(self): "[links](http://github.com/abc\\\\\\)def), " "[text\\-mention](tg://user?id=123456789) and ```\\`\\\\pre```\\. " r"http://google\.com and _bold *nested in ~strk\>trgh~ nested in* italic_\. " - "```python\nPython pre```\\. ||Spoiled||\\. ![👍](tg://emoji?id=1)\\." + "```python\nPython pre```\\. ||Spoiled||\\. ![👍](tg://emoji?id=1)\\.\n" + ">Multiline\n" + ">block quote\n" + r">with *nested*\." ) text_markdown = self.test_message_v2.text_markdown_v2 assert text_markdown == test_md_string - def test_text_markdown_new_in_v2(self, message): + @pytest.mark.parametrize( + "entity_type", + [ + MessageEntity.UNDERLINE, + MessageEntity.STRIKETHROUGH, + MessageEntity.SPOILER, + MessageEntity.BLOCKQUOTE, + MessageEntity.CUSTOM_EMOJI, + ], + ) + def test_text_markdown_new_in_v2(self, message, entity_type): message.text = "test" message.entities = [ MessageEntity(MessageEntity.BOLD, offset=0, length=4), @@ -573,16 +853,8 @@ def test_text_markdown_new_in_v2(self, message): with pytest.raises(ValueError, match="Nested entities are not supported for"): assert message.text_markdown - message.entities = [MessageEntity(MessageEntity.UNDERLINE, offset=0, length=4)] - with pytest.raises(ValueError, match="Underline entities are not supported for"): - message.text_markdown - - message.entities = [MessageEntity(MessageEntity.STRIKETHROUGH, offset=0, length=4)] - with pytest.raises(ValueError, match="Strikethrough entities are not supported for"): - message.text_markdown - - message.entities = [MessageEntity(MessageEntity.SPOILER, offset=0, length=4)] - with pytest.raises(ValueError, match="Spoiler entities are not supported for"): + message.entities = [MessageEntity(entity_type, offset=0, length=4)] + with pytest.raises(ValueError, match="entities are not supported for"): message.text_markdown message.entities = [] @@ -610,7 +882,10 @@ def test_text_markdown_v2_urled(self): "[text\\-mention](tg://user?id=123456789) and ```\\`\\\\pre```\\. " r"[http://google\.com](http://google.com) and _bold *nested in ~strk\>trgh~ " "nested in* italic_\\. ```python\nPython pre```\\. ||Spoiled||\\. " - "![👍](tg://emoji?id=1)\\." + "![👍](tg://emoji?id=1)\\.\n" + ">Multiline\n" + ">block quote\n" + r">with *nested*\." ) text_markdown = self.test_message_v2.text_markdown_v2_urled assert text_markdown == test_md_string @@ -661,7 +936,7 @@ def test_text_custom_emoji_md_v1(self, type_, recwarn): text=text, entities=[emoji_entity], ) - with pytest.raises(ValueError, match="Custom emoji entities are not supported for"): + with pytest.raises(ValueError, match="Custom Emoji entities are not supported for"): getattr(message, type_) @pytest.mark.parametrize( @@ -726,7 +1001,8 @@ def test_caption_html_simple(self): "and bold nested in strk>trgh nested in italic. " '
Python pre
. ' 'Spoiled. ' - '👍.' + '👍.\n' + "
Multiline\nblock quote\nwith nested.
" ) caption_html = self.test_message_v2.caption_html assert caption_html == test_html_string @@ -746,7 +1022,8 @@ def test_caption_html_urled(self): "and bold nested in strk>trgh nested in italic. " '
Python pre
. ' 'Spoiled. ' - '👍.' + '👍.\n' + "
Multiline\nblock quote\nwith nested.
" ) caption_html = self.test_message_v2.caption_html_urled assert caption_html == test_html_string @@ -767,7 +1044,10 @@ def test_caption_markdown_v2_simple(self): "[links](http://github.com/abc\\\\\\)def), " "[text\\-mention](tg://user?id=123456789) and ```\\`\\\\pre```\\. " r"http://google\.com and _bold *nested in ~strk\>trgh~ nested in* italic_\. " - "```python\nPython pre```\\. ||Spoiled||\\. ![👍](tg://emoji?id=1)\\." + "```python\nPython pre```\\. ||Spoiled||\\. ![👍](tg://emoji?id=1)\\.\n" + ">Multiline\n" + ">block quote\n" + r">with *nested*\." ) caption_markdown = self.test_message_v2.caption_markdown_v2 assert caption_markdown == test_md_string @@ -795,7 +1075,10 @@ def test_caption_markdown_v2_urled(self): "[text\\-mention](tg://user?id=123456789) and ```\\`\\\\pre```\\. " r"[http://google\.com](http://google.com) and _bold *nested in ~strk\>trgh~ " "nested in* italic_\\. ```python\nPython pre```\\. ||Spoiled||\\. " - "![👍](tg://emoji?id=1)\\." + "![👍](tg://emoji?id=1)\\.\n" + ">Multiline\n" + ">block quote\n" + r">with *nested*\." ) caption_markdown = self.test_message_v2.caption_markdown_v2_urled assert caption_markdown == test_md_string @@ -851,7 +1134,7 @@ def test_caption_custom_emoji_md_v1(self, type_, recwarn): caption=caption, caption_entities=[emoji_entity], ) - with pytest.raises(ValueError, match="Custom emoji entities are not supported for"): + with pytest.raises(ValueError, match="Custom Emoji entities are not supported for"): getattr(message, type_) @pytest.mark.parametrize( @@ -1001,26 +1284,201 @@ def test_effective_attachment(self, message_params): ) assert not condition, "effective_attachment was None even though it should not be" + def test_compute_quote_position_and_entities_false_index(self, message): + message.text = "AA" + with pytest.raises( + ValueError, + match="You requested the 5-th occurrence of 'A', " + "but this text appears only 2 times.", + ): + message.compute_quote_position_and_entities("A", 5) + + def test_compute_quote_position_and_entities_no_text_or_caption(self, message): + message.text = None + message.caption = None + with pytest.raises( + RuntimeError, + match="This message has neither text nor caption.", + ): + message.compute_quote_position_and_entities("A", 5) + + @pytest.mark.parametrize( + ("text", "quote", "index", "expected"), + argvalues=[ + ("AA", "A", None, 0), + ("AA", "A", 0, 0), + ("AA", "A", 1, 1), + ("ABC ABC ABC ABC", "ABC", None, 0), + ("ABC ABC ABC ABC", "ABC", 0, 0), + ("ABC ABC ABC ABC", "ABC", 3, 12), + ("👨‍👨‍👧👨‍👨‍👧👨‍👨‍👧👨‍👨‍👧", "👨‍👨‍👧", 0, 0), + ("👨‍👨‍👧👨‍👨‍👧👨‍👨‍👧👨‍👨‍👧", "👨‍👨‍👧", 3, 24), + ("👨‍👨‍👧👨‍👨‍👧👨‍👨‍👧👨‍👨‍👧", "👨", 1, 3), + ("👨‍👨‍👧👨‍👨‍👧👨‍👨‍👧👨‍👨‍👧", "👧", 2, 22), + ], + ) + @pytest.mark.parametrize("caption", [True, False]) + def test_compute_quote_position_and_entities_position( + self, message, text, quote, index, expected, caption + ): + if caption: + message.caption = text + message.text = None + else: + message.text = text + message.caption = None + + assert message.compute_quote_position_and_entities(quote, index)[0] == expected + + def test_compute_quote_position_and_entities_entities(self, message): + message.text = "A A A" + message.entities = () + assert message.compute_quote_position_and_entities("A", 0)[1] is None + + message.entities = ( + # covers complete string + MessageEntity(type=MessageEntity.BOLD, offset=0, length=6), + # covers first 2 As only + MessageEntity(type=MessageEntity.ITALIC, offset=0, length=3), + # covers second 2 As only + MessageEntity(type=MessageEntity.UNDERLINE, offset=2, length=3), + # covers middle A only + MessageEntity(type=MessageEntity.STRIKETHROUGH, offset=2, length=1), + # covers only whitespace, should be ignored + MessageEntity(type=MessageEntity.CODE, offset=1, length=1), + ) + + assert message.compute_quote_position_and_entities("A", 0)[1] == ( + MessageEntity(type=MessageEntity.BOLD, offset=0, length=1), + MessageEntity(type=MessageEntity.ITALIC, offset=0, length=1), + ) + + assert message.compute_quote_position_and_entities("A", 1)[1] == ( + MessageEntity(type=MessageEntity.BOLD, offset=0, length=1), + MessageEntity(type=MessageEntity.ITALIC, offset=0, length=1), + MessageEntity(type=MessageEntity.UNDERLINE, offset=0, length=1), + MessageEntity(type=MessageEntity.STRIKETHROUGH, offset=0, length=1), + ) + + assert message.compute_quote_position_and_entities("A", 2)[1] == ( + MessageEntity(type=MessageEntity.BOLD, offset=0, length=1), + MessageEntity(type=MessageEntity.UNDERLINE, offset=0, length=1), + ) + + @pytest.mark.parametrize( + ("target_chat_id", "expected"), + argvalues=[ + (None, 3), + (3, 3), + (-1003, -1003), + ("@username", "@username"), + ], + ) + def test_build_reply_arguments_chat_id_and_message_id(self, message, target_chat_id, expected): + message.chat.id = 3 + reply_kwargs = message.build_reply_arguments(target_chat_id=target_chat_id) + assert reply_kwargs["chat_id"] == expected + assert reply_kwargs["reply_parameters"].chat_id == (None if expected == 3 else 3) + assert reply_kwargs["reply_parameters"].message_id == message.message_id + + @pytest.mark.parametrize( + ("target_chat_id", "message_thread_id", "expected"), + argvalues=[ + (None, None, True), + (None, 123, True), + (None, 0, False), + (None, -1, False), + (3, None, True), + (3, 123, True), + (3, 0, False), + (3, -1, False), + (-1003, None, False), + (-1003, 123, False), + (-1003, 0, False), + (-1003, -1, False), + ("@username", None, True), + ("@username", 123, True), + ("@username", 0, False), + ("@username", -1, False), + ("@other_username", None, False), + ("@other_username", 123, False), + ("@other_username", 0, False), + ("@other_username", -1, False), + ], + ) + def test_build_reply_arguments_aswr( + self, message, target_chat_id, message_thread_id, expected + ): + message.chat.id = 3 + message.chat.username = "username" + message.message_thread_id = 123 + assert ( + message.build_reply_arguments( + target_chat_id=target_chat_id, message_thread_id=message_thread_id + )["reply_parameters"].allow_sending_without_reply + is not None + ) == expected + + assert ( + message.build_reply_arguments( + target_chat_id=target_chat_id, + message_thread_id=message_thread_id, + allow_sending_without_reply="custom", + )["reply_parameters"].allow_sending_without_reply + ) == ("custom" if expected else None) + + def test_build_reply_arguments_quote(self, message, monkeypatch): + reply_parameters = message.build_reply_arguments()["reply_parameters"] + assert reply_parameters.quote is None + assert reply_parameters.quote_entities == () + assert reply_parameters.quote_position is None + assert not reply_parameters.quote_parse_mode + + quote_obj = object() + quote_index = object() + quote_entities = (object(), object()) + quote_position = object() + + def mock_compute(quote, index): + if quote is quote_obj and index is quote_index: + return quote_position, quote_entities + return False, False + + monkeypatch.setattr(message, "compute_quote_position_and_entities", mock_compute) + reply_parameters = message.build_reply_arguments(quote=quote_obj, quote_index=quote_index)[ + "reply_parameters" + ] + + assert reply_parameters.quote is quote_obj + assert reply_parameters.quote_entities is quote_entities + assert reply_parameters.quote_position is quote_position + assert not reply_parameters.quote_parse_mode + async def test_reply_text(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id text = kwargs["text"] == "test" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and text and reply + return id_ and text assert check_shortcut_signature( - Message.reply_text, Bot.send_message, ["chat_id"], ["quote"] + Message.reply_text, + Bot.send_message, + ["chat_id", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], + ) + assert await check_shortcut_call( + message.reply_text, + message.get_bot(), + "send_message", + skip_params=["reply_to_message_id"], ) - assert await check_shortcut_call(message.reply_text, message.get_bot(), "send_message") assert await check_defaults_handling(message.reply_text, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_message", make_assertion) assert await message.reply_text("test") - assert await message.reply_text("test", quote=True) - assert await message.reply_text("test", reply_to_message_id=message.message_id, quote=True) + await self.check_quote_parsing( + message, message.reply_text, "send_message", ["test"], monkeypatch + ) async def test_reply_markdown(self, monkeypatch, message): test_md_string = ( @@ -1034,16 +1492,20 @@ async def make_assertion(*_, **kwargs): cid = kwargs["chat_id"] == message.chat_id markdown_text = kwargs["text"] == test_md_string markdown_enabled = kwargs["parse_mode"] == ParseMode.MARKDOWN - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return all([cid, markdown_text, reply, markdown_enabled]) + return all([cid, markdown_text, markdown_enabled]) assert check_shortcut_signature( - Message.reply_markdown, Bot.send_message, ["chat_id", "parse_mode"], ["quote"] + Message.reply_markdown, + Bot.send_message, + ["chat_id", "parse_mode", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], + ) + assert await check_shortcut_call( + message.reply_text, + message.get_bot(), + "send_message", + skip_params=["reply_to_message_id"], ) - assert await check_shortcut_call(message.reply_text, message.get_bot(), "send_message") assert await check_defaults_handling(message.reply_text, message.get_bot()) text_markdown = self.test_message.text_markdown @@ -1051,10 +1513,6 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(message.get_bot(), "send_message", make_assertion) assert await message.reply_markdown(self.test_message.text_markdown) - assert await message.reply_markdown(self.test_message.text_markdown, quote=True) - assert await message.reply_markdown( - self.test_message.text_markdown, reply_to_message_id=message.message_id, quote=True - ) async def test_reply_markdown_v2(self, monkeypatch, message): test_md_string = ( @@ -1062,23 +1520,30 @@ async def test_reply_markdown_v2(self, monkeypatch, message): "[links](http://github.com/abc\\\\\\)def), " "[text\\-mention](tg://user?id=123456789) and ```\\`\\\\pre```\\. " r"http://google\.com and _bold *nested in ~strk\>trgh~ nested in* italic_\. " - "```python\nPython pre```\\. ||Spoiled||\\. ![👍](tg://emoji?id=1)\\." + "```python\nPython pre```\\. ||Spoiled||\\. ![👍](tg://emoji?id=1)\\.\n" + ">Multiline\n" + ">block quote\n" + r">with *nested*\." ) async def make_assertion(*_, **kwargs): cid = kwargs["chat_id"] == message.chat_id markdown_text = kwargs["text"] == test_md_string markdown_enabled = kwargs["parse_mode"] == ParseMode.MARKDOWN_V2 - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return all([cid, markdown_text, reply, markdown_enabled]) + return all([cid, markdown_text, markdown_enabled]) assert check_shortcut_signature( - Message.reply_markdown_v2, Bot.send_message, ["chat_id", "parse_mode"], ["quote"] + Message.reply_markdown_v2, + Bot.send_message, + ["chat_id", "parse_mode", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], + ) + assert await check_shortcut_call( + message.reply_text, + message.get_bot(), + "send_message", + skip_params=["reply_to_message_id"], ) - assert await check_shortcut_call(message.reply_text, message.get_bot(), "send_message") assert await check_defaults_handling(message.reply_text, message.get_bot()) text_markdown = self.test_message_v2.text_markdown_v2 @@ -1086,11 +1551,8 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(message.get_bot(), "send_message", make_assertion) assert await message.reply_markdown_v2(self.test_message_v2.text_markdown_v2) - assert await message.reply_markdown_v2(self.test_message_v2.text_markdown_v2, quote=True) - assert await message.reply_markdown_v2( - self.test_message_v2.text_markdown_v2, - reply_to_message_id=message.message_id, - quote=True, + await self.check_quote_parsing( + message, message.reply_markdown_v2, "send_message", [test_md_string], monkeypatch ) async def test_reply_html(self, monkeypatch, message): @@ -1103,23 +1565,28 @@ async def test_reply_html(self, monkeypatch, message): "and bold nested in strk>trgh nested in italic. " '
Python pre
. ' 'Spoiled. ' - '👍.' + '👍.\n' + "
Multiline\nblock quote\nwith nested.
" ) async def make_assertion(*_, **kwargs): cid = kwargs["chat_id"] == message.chat_id html_text = kwargs["text"] == test_html_string html_enabled = kwargs["parse_mode"] == ParseMode.HTML - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return all([cid, html_text, reply, html_enabled]) + return all([cid, html_text, html_enabled]) assert check_shortcut_signature( - Message.reply_html, Bot.send_message, ["chat_id", "parse_mode"], ["quote"] + Message.reply_html, + Bot.send_message, + ["chat_id", "parse_mode", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], + ) + assert await check_shortcut_call( + message.reply_text, + message.get_bot(), + "send_message", + skip_params=["reply_to_message_id"], ) - assert await check_shortcut_call(message.reply_text, message.get_bot(), "send_message") assert await check_defaults_handling(message.reply_text, message.get_bot()) text_html = self.test_message_v2.text_html @@ -1127,297 +1594,376 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(message.get_bot(), "send_message", make_assertion) assert await message.reply_html(self.test_message_v2.text_html) - assert await message.reply_html(self.test_message_v2.text_html, quote=True) - assert await message.reply_html( - self.test_message_v2.text_html, reply_to_message_id=message.message_id, quote=True + await self.check_quote_parsing( + message, message.reply_html, "send_message", [test_html_string], monkeypatch ) async def test_reply_media_group(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id media = kwargs["media"] == "reply_media_group" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and media and reply + return id_ and media assert check_shortcut_signature( - Message.reply_media_group, Bot.send_media_group, ["chat_id"], ["quote"] + Message.reply_media_group, + Bot.send_media_group, + ["chat_id", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], ) assert await check_shortcut_call( - message.reply_media_group, message.get_bot(), "send_media_group" + message.reply_media_group, + message.get_bot(), + "send_media_group", + skip_params=["reply_to_message_id"], ) assert await check_defaults_handling(message.reply_media_group, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_media_group", make_assertion) assert await message.reply_media_group(media="reply_media_group") - assert await message.reply_media_group(media="reply_media_group", quote=True) + await self.check_quote_parsing( + message, + message.reply_media_group, + "send_media_group", + ["reply_media_group"], + monkeypatch, + ) async def test_reply_photo(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id photo = kwargs["photo"] == "test_photo" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and photo and reply + return id_ and photo assert check_shortcut_signature( - Message.reply_photo, Bot.send_photo, ["chat_id"], ["quote"] + Message.reply_photo, + Bot.send_photo, + ["chat_id", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], + ) + assert await check_shortcut_call( + message.reply_photo, + message.get_bot(), + "send_photo", + skip_params=["reply_to_message_id"], ) - assert await check_shortcut_call(message.reply_photo, message.get_bot(), "send_photo") assert await check_defaults_handling(message.reply_photo, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_photo", make_assertion) assert await message.reply_photo(photo="test_photo") - assert await message.reply_photo(photo="test_photo", quote=True) + await self.check_quote_parsing( + message, message.reply_photo, "send_photo", ["test_photo"], monkeypatch + ) async def test_reply_audio(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id audio = kwargs["audio"] == "test_audio" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and audio and reply + return id_ and audio assert check_shortcut_signature( - Message.reply_audio, Bot.send_audio, ["chat_id"], ["quote"] + Message.reply_audio, + Bot.send_audio, + ["chat_id", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], + ) + assert await check_shortcut_call( + message.reply_audio, + message.get_bot(), + "send_audio", + skip_params=["reply_to_message_id"], ) - assert await check_shortcut_call(message.reply_audio, message.get_bot(), "send_audio") assert await check_defaults_handling(message.reply_audio, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_audio", make_assertion) assert await message.reply_audio(audio="test_audio") - assert await message.reply_audio(audio="test_audio", quote=True) + await self.check_quote_parsing( + message, message.reply_audio, "send_audio", ["test_audio"], monkeypatch + ) async def test_reply_document(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id document = kwargs["document"] == "test_document" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and document and reply + return id_ and document assert check_shortcut_signature( - Message.reply_document, Bot.send_document, ["chat_id"], ["quote"] + Message.reply_document, + Bot.send_document, + ["chat_id", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], ) assert await check_shortcut_call( - message.reply_document, message.get_bot(), "send_document" + message.reply_document, + message.get_bot(), + "send_document", + skip_params=["reply_to_message_id"], ) assert await check_defaults_handling(message.reply_document, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_document", make_assertion) assert await message.reply_document(document="test_document") - assert await message.reply_document(document="test_document", quote=True) + await self.check_quote_parsing( + message, message.reply_document, "send_document", ["test_document"], monkeypatch + ) async def test_reply_animation(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id animation = kwargs["animation"] == "test_animation" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and animation and reply + return id_ and animation assert check_shortcut_signature( - Message.reply_animation, Bot.send_animation, ["chat_id"], ["quote"] + Message.reply_animation, + Bot.send_animation, + ["chat_id", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], ) assert await check_shortcut_call( - message.reply_animation, message.get_bot(), "send_animation" + message.reply_animation, + message.get_bot(), + "send_animation", + skip_params=["reply_to_message_id"], ) assert await check_defaults_handling(message.reply_animation, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_animation", make_assertion) assert await message.reply_animation(animation="test_animation") - assert await message.reply_animation(animation="test_animation", quote=True) + await self.check_quote_parsing( + message, message.reply_animation, "send_animation", ["test_animation"], monkeypatch + ) async def test_reply_sticker(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id sticker = kwargs["sticker"] == "test_sticker" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and sticker and reply + return id_ and sticker assert check_shortcut_signature( - Message.reply_sticker, Bot.send_sticker, ["chat_id"], ["quote"] + Message.reply_sticker, + Bot.send_sticker, + ["chat_id", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], + ) + assert await check_shortcut_call( + message.reply_sticker, + message.get_bot(), + "send_sticker", + skip_params=["reply_to_message_id"], ) - assert await check_shortcut_call(message.reply_sticker, message.get_bot(), "send_sticker") assert await check_defaults_handling(message.reply_sticker, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_sticker", make_assertion) assert await message.reply_sticker(sticker="test_sticker") - assert await message.reply_sticker(sticker="test_sticker", quote=True) + await self.check_quote_parsing( + message, message.reply_sticker, "send_sticker", ["test_sticker"], monkeypatch + ) async def test_reply_video(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id video = kwargs["video"] == "test_video" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and video and reply + return id_ and video assert check_shortcut_signature( - Message.reply_video, Bot.send_video, ["chat_id"], ["quote"] + Message.reply_video, + Bot.send_video, + ["chat_id", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], + ) + assert await check_shortcut_call( + message.reply_video, + message.get_bot(), + "send_video", + skip_params=["reply_to_message_id"], ) - assert await check_shortcut_call(message.reply_video, message.get_bot(), "send_video") assert await check_defaults_handling(message.reply_video, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_video", make_assertion) assert await message.reply_video(video="test_video") - assert await message.reply_video(video="test_video", quote=True) + await self.check_quote_parsing( + message, message.reply_video, "send_video", ["test_video"], monkeypatch + ) async def test_reply_video_note(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id video_note = kwargs["video_note"] == "test_video_note" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and video_note and reply + return id_ and video_note assert check_shortcut_signature( - Message.reply_video_note, Bot.send_video_note, ["chat_id"], ["quote"] + Message.reply_video_note, + Bot.send_video_note, + ["chat_id", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], ) assert await check_shortcut_call( - message.reply_video_note, message.get_bot(), "send_video_note" + message.reply_video_note, + message.get_bot(), + "send_video_note", + skip_params=["reply_to_message_id"], ) assert await check_defaults_handling(message.reply_video_note, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_video_note", make_assertion) assert await message.reply_video_note(video_note="test_video_note") - assert await message.reply_video_note(video_note="test_video_note", quote=True) + await self.check_quote_parsing( + message, message.reply_video_note, "send_video_note", ["test_video_note"], monkeypatch + ) async def test_reply_voice(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id voice = kwargs["voice"] == "test_voice" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and voice and reply + return id_ and voice assert check_shortcut_signature( - Message.reply_voice, Bot.send_voice, ["chat_id"], ["quote"] + Message.reply_voice, + Bot.send_voice, + ["chat_id", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], + ) + assert await check_shortcut_call( + message.reply_voice, + message.get_bot(), + "send_voice", + skip_params=["reply_to_message_id"], ) - assert await check_shortcut_call(message.reply_voice, message.get_bot(), "send_voice") assert await check_defaults_handling(message.reply_voice, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_voice", make_assertion) assert await message.reply_voice(voice="test_voice") - assert await message.reply_voice(voice="test_voice", quote=True) + await self.check_quote_parsing( + message, message.reply_voice, "send_voice", ["test_voice"], monkeypatch + ) async def test_reply_location(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id location = kwargs["location"] == "test_location" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and location and reply + return id_ and location assert check_shortcut_signature( - Message.reply_location, Bot.send_location, ["chat_id"], ["quote"] + Message.reply_location, + Bot.send_location, + ["chat_id", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], ) assert await check_shortcut_call( - message.reply_location, message.get_bot(), "send_location" + message.reply_location, + message.get_bot(), + "send_location", + skip_params=["reply_to_message_id"], ) assert await check_defaults_handling(message.reply_location, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_location", make_assertion) assert await message.reply_location(location="test_location") - assert await message.reply_location(location="test_location", quote=True) + await self.check_quote_parsing( + message, message.reply_location, "send_location", ["test_location"], monkeypatch + ) async def test_reply_venue(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id venue = kwargs["venue"] == "test_venue" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and venue and reply + return id_ and venue assert check_shortcut_signature( - Message.reply_venue, Bot.send_venue, ["chat_id"], ["quote"] + Message.reply_venue, + Bot.send_venue, + ["chat_id", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], + ) + assert await check_shortcut_call( + message.reply_venue, + message.get_bot(), + "send_venue", + skip_params=["reply_to_message_id"], ) - assert await check_shortcut_call(message.reply_venue, message.get_bot(), "send_venue") assert await check_defaults_handling(message.reply_venue, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_venue", make_assertion) assert await message.reply_venue(venue="test_venue") - assert await message.reply_venue(venue="test_venue", quote=True) + await self.check_quote_parsing( + message, message.reply_venue, "send_venue", ["test_venue"], monkeypatch + ) async def test_reply_contact(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id contact = kwargs["contact"] == "test_contact" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and contact and reply + return id_ and contact assert check_shortcut_signature( - Message.reply_contact, Bot.send_contact, ["chat_id"], ["quote"] + Message.reply_contact, + Bot.send_contact, + ["chat_id", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], + ) + assert await check_shortcut_call( + message.reply_contact, + message.get_bot(), + "send_contact", + skip_params=["reply_to_message_id"], ) - assert await check_shortcut_call(message.reply_contact, message.get_bot(), "send_contact") assert await check_defaults_handling(message.reply_contact, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_contact", make_assertion) assert await message.reply_contact(contact="test_contact") - assert await message.reply_contact(contact="test_contact", quote=True) + await self.check_quote_parsing( + message, message.reply_contact, "send_contact", ["test_contact"], monkeypatch + ) async def test_reply_poll(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id question = kwargs["question"] == "test_poll" options = kwargs["options"] == ["1", "2", "3"] - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and question and options and reply + return id_ and question and options - assert check_shortcut_signature(Message.reply_poll, Bot.send_poll, ["chat_id"], ["quote"]) - assert await check_shortcut_call(message.reply_poll, message.get_bot(), "send_poll") + assert check_shortcut_signature( + Message.reply_poll, + Bot.send_poll, + ["chat_id", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], + ) + assert await check_shortcut_call( + message.reply_poll, message.get_bot(), "send_poll", skip_params=["reply_to_message_id"] + ) assert await check_defaults_handling(message.reply_poll, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_poll", make_assertion) assert await message.reply_poll(question="test_poll", options=["1", "2", "3"]) - assert await message.reply_poll(question="test_poll", quote=True, options=["1", "2", "3"]) + await self.check_quote_parsing( + message, message.reply_poll, "send_poll", ["test_poll", ["1", "2", "3"]], monkeypatch + ) async def test_reply_dice(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id contact = kwargs["disable_notification"] is True - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and contact and reply + return id_ and contact - assert check_shortcut_signature(Message.reply_dice, Bot.send_dice, ["chat_id"], ["quote"]) - assert await check_shortcut_call(message.reply_dice, message.get_bot(), "send_dice") + assert check_shortcut_signature( + Message.reply_dice, + Bot.send_dice, + ["chat_id", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], + ) + assert await check_shortcut_call( + message.reply_dice, message.get_bot(), "send_dice", skip_params=["reply_to_message_id"] + ) assert await check_defaults_handling(message.reply_dice, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_dice", make_assertion) assert await message.reply_dice(disable_notification=True) - assert await message.reply_dice(disable_notification=True, quote=True) + await self.check_quote_parsing( + message, + message.reply_dice, + "send_dice", + [], + monkeypatch, + ) async def test_reply_action(self, monkeypatch, message: Message): async def make_assertion(*_, **kwargs): @@ -1426,7 +1972,7 @@ async def make_assertion(*_, **kwargs): return id_ and action assert check_shortcut_signature( - Message.reply_chat_action, Bot.send_chat_action, ["chat_id"], [] + Message.reply_chat_action, Bot.send_chat_action, ["chat_id", "reply_to_message_id"], [] ) assert await check_shortcut_call( message.reply_chat_action, message.get_bot(), "send_chat_action" @@ -1442,13 +1988,22 @@ async def make_assertion(*_, **kwargs): kwargs["chat_id"] == message.chat_id and kwargs["game_short_name"] == "test_game" ) - assert check_shortcut_signature(Message.reply_game, Bot.send_game, ["chat_id"], ["quote"]) - assert await check_shortcut_call(message.reply_game, message.get_bot(), "send_game") + assert check_shortcut_signature( + Message.reply_game, + Bot.send_game, + ["chat_id", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], + ) + assert await check_shortcut_call( + message.reply_game, message.get_bot(), "send_game", skip_params=["reply_to_message_id"] + ) assert await check_defaults_handling(message.reply_game, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_game", make_assertion) assert await message.reply_game(game_short_name="test_game") - assert await message.reply_game(game_short_name="test_game", quote=True) + await self.check_quote_parsing( + message, message.reply_game, "send_game", ["test_game"], monkeypatch + ) async def test_reply_invoice(self, monkeypatch, message): async def make_assertion(*_, **kwargs): @@ -1462,9 +2017,17 @@ async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == message.chat_id and args assert check_shortcut_signature( - Message.reply_invoice, Bot.send_invoice, ["chat_id"], ["quote"] + Message.reply_invoice, + Bot.send_invoice, + ["chat_id", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], + ) + assert await check_shortcut_call( + message.reply_invoice, + message.get_bot(), + "send_invoice", + skip_params=["reply_to_message_id"], ) - assert await check_shortcut_call(message.reply_invoice, message.get_bot(), "send_invoice") assert await check_defaults_handling(message.reply_invoice, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_invoice", make_assertion) @@ -1476,14 +2039,12 @@ async def make_assertion(*_, **kwargs): "currency", "prices", ) - assert await message.reply_invoice( - "title", - "description", - "payload", - "provider_token", - "currency", - "prices", - quote=True, + await self.check_quote_parsing( + message, + message.reply_invoice, + "send_invoice", + ["title", "description", "payload", "provider_token", "currency", "prices"], + monkeypatch, ) @pytest.mark.parametrize(("disable_notification", "protected"), [(False, True), (True, False)]) @@ -1563,22 +2124,20 @@ async def make_assertion(*_, **kwargs): reply_markup = kwargs["reply_markup"] is keyboard else: reply_markup = True - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True return ( chat_id and from_chat and message_id and notification and reply_markup - and reply and is_protected ) assert check_shortcut_signature( - Message.reply_copy, Bot.copy_message, ["chat_id"], ["quote"] + Message.reply_copy, + Bot.copy_message, + ["chat_id", "reply_to_message_id"], + ["quote", "do_quote", "reply_to_message_id"], ) assert await check_shortcut_call(message.copy, message.get_bot(), "copy_message") assert await check_defaults_handling(message.copy, message.get_bot()) @@ -1594,20 +2153,12 @@ async def make_assertion(*_, **kwargs): disable_notification=disable_notification, protect_content=protected, ) - assert await message.reply_copy( - 123456, - 456789, - quote=True, - disable_notification=disable_notification, - protect_content=protected, - ) - assert await message.reply_copy( - 123456, - 456789, - quote=True, - reply_to_message_id=message.message_id, - disable_notification=disable_notification, - protect_content=protected, + await self.check_quote_parsing( + message, + message.reply_copy, + "copy_message", + [123456, 456789], + monkeypatch, ) async def test_edit_text(self, monkeypatch, message): @@ -1876,22 +2427,38 @@ async def make_assertion(*args, **kwargs): monkeypatch.setattr(message.get_bot(), "unpin_chat_message", make_assertion) assert await message.unpin() - def test_default_quote(self, message): - message.get_bot()._defaults = Defaults() - - try: - message.get_bot().defaults._quote = False - assert message._quote(None, None) is None + @pytest.mark.parametrize( + ("default_quote", "chat_type", "expected"), + [ + (False, Chat.PRIVATE, False), + (None, Chat.PRIVATE, False), + (True, Chat.PRIVATE, True), + (False, Chat.GROUP, False), + (None, Chat.GROUP, True), + (True, Chat.GROUP, True), + (False, Chat.SUPERGROUP, False), + (None, Chat.SUPERGROUP, True), + (True, Chat.SUPERGROUP, True), + (False, Chat.CHANNEL, False), + (None, Chat.CHANNEL, True), + (True, Chat.CHANNEL, True), + ], + ) + async def test_default_do_quote( + self, bot, message, default_quote, chat_type, expected, monkeypatch + ): + message.set_bot(PytestExtBot(token=bot.token, defaults=Defaults(do_quote=default_quote))) - message.get_bot().defaults._quote = True - assert message._quote(None, None) == message.message_id + async def make_assertion(*_, **kwargs): + reply_parameters = kwargs.get("reply_parameters") or ReplyParameters(message_id=False) + condition = reply_parameters.message_id == message.message_id + return condition == expected - message.get_bot().defaults._quote = None - message.chat.type = Chat.PRIVATE - assert message._quote(None, None) is None + monkeypatch.setattr(message.get_bot(), "send_message", make_assertion) - message.chat.type = Chat.GROUP - assert message._quote(None, None) + try: + message.chat.type = chat_type + assert await message.reply_text("test") finally: message.get_bot()._defaults = None diff --git a/tests/test_messageorigin.py b/tests/test_messageorigin.py new file mode 100644 index 00000000000..554cedcc914 --- /dev/null +++ b/tests/test_messageorigin.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2023 +# 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 datetime +import inspect +from copy import deepcopy + +import pytest + +from telegram import ( + Chat, + Dice, + MessageOrigin, + MessageOriginChannel, + MessageOriginChat, + MessageOriginHiddenUser, + MessageOriginUser, + User, +) +from telegram._utils.datetime import UTC, to_timestamp +from tests.auxil.slots import mro_slots + +ignored = ["self", "api_kwargs"] + + +class MODefaults: + date: datetime.datetime = to_timestamp(datetime.datetime.utcnow()) + chat = Chat(1, Chat.CHANNEL) + message_id = 123 + author_signautre = "PTB" + sender_chat = Chat(1, Chat.CHANNEL) + sender_user_name = "PTB" + sender_user = User(1, "user", False) + + +def message_origin_channel(): + return MessageOriginChannel( + MODefaults.date, MODefaults.chat, MODefaults.message_id, MODefaults.author_signautre + ) + + +def message_origin_chat(): + return MessageOriginChat( + MODefaults.date, + MODefaults.sender_chat, + MODefaults.author_signautre, + ) + + +def message_origin_hidden_user(): + return MessageOriginHiddenUser(MODefaults.date, MODefaults.sender_user_name) + + +def message_origin_user(): + return MessageOriginUser(MODefaults.date, MODefaults.sender_user) + + +def make_json_dict(instance: MessageOrigin, include_optional_args: bool = False) -> dict: + """Used to make the json dict which we use for testing de_json. Similar to iter_args()""" + json_dict = {"type": instance.type} + sig = inspect.signature(instance.__class__.__init__) + + for param in sig.parameters.values(): + if param.name in ignored: # ignore irrelevant params + continue + + val = getattr(instance, param.name) + # Compulsory args- + if param.default is inspect.Parameter.empty: + if hasattr(val, "to_dict"): # convert the user object or any future ones to dict. + val = val.to_dict() + json_dict[param.name] = val + + # If we want to test all args (for de_json)- + elif param.default is not inspect.Parameter.empty and include_optional_args: + json_dict[param.name] = val + return json_dict + + +def iter_args( + instance: MessageOrigin, de_json_inst: MessageOrigin, include_optional: bool = False +): + """ + We accept both the regular instance and de_json created instance and iterate over them for + easy one line testing later one. + """ + yield instance.type, de_json_inst.type # yield this here cause it's not available in sig. + + sig = inspect.signature(instance.__class__.__init__) + for param in sig.parameters.values(): + if param.name in ignored: + continue + inst_at, json_at = getattr(instance, param.name), getattr(de_json_inst, param.name) + if isinstance(json_at, datetime.datetime): # Convert datetime to int + json_at = to_timestamp(json_at) + if ( + param.default is not inspect.Parameter.empty and include_optional + ) or param.default is inspect.Parameter.empty: + yield inst_at, json_at + + +@pytest.fixture() +def message_origin_type(request): + return request.param() + + +@pytest.mark.parametrize( + "message_origin_type", + [ + message_origin_channel, + message_origin_chat, + message_origin_hidden_user, + message_origin_user, + ], + indirect=True, +) +class TestMessageOriginTypesWithoutRequest: + def test_slot_behaviour(self, message_origin_type): + inst = message_origin_type + 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_required_args(self, bot, message_origin_type): + cls = message_origin_type.__class__ + assert cls.de_json({}, bot) is None + + json_dict = make_json_dict(message_origin_type) + const_message_origin = MessageOrigin.de_json(json_dict, bot) + assert const_message_origin.api_kwargs == {} + + assert isinstance(const_message_origin, MessageOrigin) + assert isinstance(const_message_origin, cls) + for msg_origin_type_at, const_msg_origin_at in iter_args( + message_origin_type, const_message_origin + ): + assert msg_origin_type_at == const_msg_origin_at + + def test_de_json_all_args(self, bot, message_origin_type): + json_dict = make_json_dict(message_origin_type, include_optional_args=True) + const_message_origin = MessageOrigin.de_json(json_dict, bot) + + assert const_message_origin.api_kwargs == {} + + assert isinstance(const_message_origin, MessageOrigin) + assert isinstance(const_message_origin, message_origin_type.__class__) + for msg_origin_type_at, const_msg_origin_at in iter_args( + message_origin_type, const_message_origin, True + ): + assert msg_origin_type_at == const_msg_origin_at + + def test_de_json_messageorigin_localization(self, message_origin_type, tz_bot, bot, raw_bot): + json_dict = make_json_dict(message_origin_type, include_optional_args=True) + msgorigin_raw = MessageOrigin.de_json(json_dict, raw_bot) + msgorigin_bot = MessageOrigin.de_json(json_dict, bot) + msgorigin_tz = MessageOrigin.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + msgorigin_offset = msgorigin_tz.date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset(msgorigin_tz.date.replace(tzinfo=None)) + + assert msgorigin_raw.date.tzinfo == UTC + assert msgorigin_bot.date.tzinfo == UTC + assert msgorigin_offset == tz_bot_offset + + def test_de_json_invalid_type(self, message_origin_type, bot): + json_dict = {"type": "invalid", "date": MODefaults.date} + message_origin_type = MessageOrigin.de_json(json_dict, bot) + + assert type(message_origin_type) is MessageOrigin + assert message_origin_type.type == "invalid" + + def test_de_json_subclass(self, message_origin_type, bot, chat_id): + """This makes sure that e.g. MessageOriginChat(data, bot) never returns a + MessageOriginUser instance.""" + cls = message_origin_type.__class__ + json_dict = make_json_dict(message_origin_type, True) + assert type(cls.de_json(json_dict, bot)) is cls + + def test_to_dict(self, message_origin_type): + message_origin_dict = message_origin_type.to_dict() + + assert isinstance(message_origin_dict, dict) + assert message_origin_dict["type"] == message_origin_type.type + assert message_origin_dict["date"] == message_origin_type.date + + for slot in message_origin_type.__slots__: # additional verification for the optional args + if slot in ("chat", "sender_chat", "sender_user"): + assert (getattr(message_origin_type, slot)).to_dict() == message_origin_dict[slot] + continue + assert getattr(message_origin_type, slot) == message_origin_dict[slot] + + def test_equality(self, message_origin_type): + a = MessageOrigin(type="type", date=MODefaults.date) + b = MessageOrigin(type="type", date=MODefaults.date) + c = message_origin_type + d = deepcopy(message_origin_type) + e = Dice(4, "emoji") + + 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) + + assert c == d + assert hash(c) == hash(d) + + assert c != e + assert hash(c) != hash(e) diff --git a/tests/test_official.py b/tests/test_official.py index c1204e322c8..8aaca8a6980 100644 --- a/tests/test_official.py +++ b/tests/test_official.py @@ -43,27 +43,6 @@ "api_kwargs", } -# Arguments *added* to the official API -PTB_EXTRA_PARAMS = { - "send_contact": {"contact"}, - "send_location": {"location"}, - "edit_message_live_location": {"location"}, - "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"}, - "InlineQueryResult": {"id", "type"}, # attributes common to all subclasses - "ChatMember": {"user", "status"}, # attributes common to all subclasses - "BotCommandScope": {"type"}, # attributes common to all subclasses - "MenuButton": {"type"}, # attributes common to all subclasses - "PassportFile": {"credentials"}, - "EncryptedPassportElement": {"credentials"}, - "PassportElementError": {"source", "type", "message"}, - "InputMedia": {"caption", "caption_entities", "media", "media_type", "parse_mode"}, - "InputMedia(Animation|Audio|Document|Photo|Video|VideoNote|Voice)": {"filename"}, - "InputFile": {"attach", "filename", "obj"}, -} - # Types for certain parameters accepted by PTB but not in the official API ADDITIONAL_TYPES = { "photo": ForwardRef("PhotoSize"), @@ -82,13 +61,14 @@ "results": "InlineQueryResult", # + Callable "commands": "BotCommand", # + tuple[str, str] "keyboard": "KeyboardButton", # + sequence[sequence[str]] + "reaction": "ReactionType", # + str # TODO: Deprecated and will be corrected (and removed) in next major PTB version: "file_hashes": "list[str]", } # Special cases for other parameters that accept more types than the official API, and are # too complex to compare/predict with official API: -EXCEPTIONS = { # (param_name, is_class): reduced form of annotation +COMPLEX_TYPES = { # (param_name, is_class (i.e appears in a class?)): reduced form of annotation ("correct_option_id", False): int, # actual: Literal ("file_id", False): str, # actual: Union[str, objs_with_file_id_attr] ("invite_link", False): str, # actual: Union[str, ChatInviteLink] @@ -98,6 +78,15 @@ ("data", True): str, # actual: Union[IdDocumentData, PersonalDetails, ResidentialAddress] } +# These are param names ignored in the param type checking in classes for the `tg.Defaults` case. +IGNORED_DEFAULTS_PARAM_NAMES = { + "quote", + "link_preview_options", +} + +# These classes' params are all ODVInput, so we ignore them in the defaults type checking. +IGNORED_DEFAULTS_CLASSES = {"LinkPreviewOptions"} + def _get_params_base(object_name: str, search_dict: dict[str, set[Any]]) -> set[Any]: """Helper function for the *_params functions below. @@ -116,11 +105,39 @@ def _get_params_base(object_name: str, search_dict: dict[str, set[Any]]) -> set[ return out +# Arguments *added* to the official API +PTB_EXTRA_PARAMS = { + "send_contact": {"contact"}, + "send_location": {"location"}, + "edit_message_live_location": {"location"}, + "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"}, + "InlineQueryResult": {"id", "type"}, # attributes common to all subclasses + "ChatMember": {"user", "status"}, # attributes common to all subclasses + "BotCommandScope": {"type"}, # attributes common to all subclasses + "MenuButton": {"type"}, # attributes common to all subclasses + "PassportFile": {"credentials"}, + "EncryptedPassportElement": {"credentials"}, + "PassportElementError": {"source", "type", "message"}, + "InputMedia": {"caption", "caption_entities", "media", "media_type", "parse_mode"}, + "InputMedia(Animation|Audio|Document|Photo|Video|VideoNote|Voice)": {"filename"}, + "InputFile": {"attach", "filename", "obj"}, + "MaybeInaccessibleMessage": {"date", "message_id", "chat"}, # attributes common to all subcls + "ChatBoostSource": {"source"}, # attributes common to all subclasses + "MessageOrigin": {"type", "date"}, # attributes common to all subclasses + "ReactionType": {"type"}, # attributes common to all subclasses + "InputTextMessageContent": {"disable_web_page_preview"}, # convenience arg, here for bw compat +} + + def ptb_extra_params(object_name: str) -> set[str]: return _get_params_base(object_name, PTB_EXTRA_PARAMS) # Arguments *removed* from the official API +# Mostly due to the value being fixed anyway PTB_IGNORED_PARAMS = { r"InlineQueryResult\w+": {"type"}, r"ChatMember\w+": {"status"}, @@ -130,6 +147,10 @@ def ptb_extra_params(object_name: str) -> set[str]: r"BotCommandScope\w+": {"type"}, r"MenuButton\w+": {"type"}, r"InputMedia\w+": {"type"}, + "InaccessibleMessage": {"date"}, + r"MessageOrigin\w+": {"type"}, + r"ChatBoostSource\w+": {"source"}, + r"ReactionType\w+": {"type"}, } @@ -153,7 +174,25 @@ def ignored_param_requirements(object_name: str) -> set[str]: # Arguments that are optional arguments for now for backwards compatibility -BACKWARDS_COMPAT_KWARGS: dict[str, set[str]] = {} +BACKWARDS_COMPAT_KWARGS: dict[str, set[str]] = { + # Deprecated by Bot API 7.0, kept for now for bw compat: + "KeyboardButton": {"request_user"}, + "Message": { + "forward_from", + "forward_signature", + "forward_sender_name", + "forward_date", + "forward_from_chat", + "forward_from_message_id", + "user_shared", + }, + "(send_message|edit_message_text)": { + "disable_web_page_preview", + "reply_to_message_id", + "allow_sending_without_reply", + }, + r"copy_message|send_\w+": {"allow_sending_without_reply", "reply_to_message_id"}, +} def backwards_compat_kwargs(object_name: str) -> set[str]: @@ -206,7 +245,8 @@ def check_method(h4: Tag) -> None: ) if not check_param_type(param, tg_parameter, method): raise AssertionError( - f"Param {param.name!r} of {method.__name__!r} should be {tg_parameter[1]}" + f"Param {param.name!r} of {method.__name__!r} should be {tg_parameter[1]} or " + "something else!" ) # Now check if the parameter is required or not @@ -273,7 +313,8 @@ def check_object(h4: Tag) -> None: ) if not check_param_type(param, tg_parameter, obj): raise AssertionError( - f"Param {param.name!r} of {obj.__name__!r} should be {tg_parameter[1]}" + f"Param {param.name!r} of {obj.__name__!r} should be {tg_parameter[1]} or " + "something else!" ) if not check_required_param(tg_parameter, param, obj.__name__): raise AssertionError(f"{obj.__name__!r} parameter {param.name!r} requirement mismatch") @@ -336,6 +377,7 @@ def check_param_type( :obj:`bool`: The boolean returned represents whether our parameter's type annotation is the same as Telegram's or not. """ + # PRE-PROCESSING: # In order to evaluate the type annotation, we need to first have a mapping of the types # specified in the official API to our types. The keys are types in the column of official API. TYPE_MAPPING: dict[str, set[Any]] = { @@ -400,6 +442,11 @@ def check_param_type( ptb_annotation = wrapped[ptb_annotation] # We have put back our annotation together after removing the NoneType! + # CHECKING: + # Each branch may have exits in the form of return statements. If the annotation is found to be + # correct, the function will return True. If not, it will return False. + + # 1) HANDLING ARRAY TYPES: # Now let's do the checking, starting with "Array of ..." types. if "Array of " in tg_param_type: assert mapped_type is Sequence @@ -430,15 +477,32 @@ def check_param_type( # This means it is Array of [obj] return any(mapped_type[o] == ptb_annotation for o in unionized_objs) - # Special case for when the parameter is a default value parameter - for name, _ in inspect.getmembers(Defaults, lambda x: isinstance(x, property)): - if name in ptb_param.name: # no strict == since we have a param: `explanation_parse_mode` - # Check if it's ODVInput - parsed = ODVInput[mapped_type] - if (ptb_annotation | None) == parsed: # We have to add back None in our annotation - return True - return False - + # 2) HANDLING DEFAULTS PARAMETERS: + # Classes whose parameters are all ODVInput should be converted and checked. + if obj.__name__ in IGNORED_DEFAULTS_CLASSES: + parsed = ODVInput[mapped_type] + return (ptb_annotation | None) == parsed # We have to add back None in our annotation + if not ( + # Defaults checking should not be done for: + # 1. Parameters that have name conflict with `Defaults.name` + is_class + and obj.__name__ in ("ReplyParameters", "Message", "ExternalReplyInfo") + and ptb_param.name in IGNORED_DEFAULTS_PARAM_NAMES + ): + # Now let's check if the parameter is a Defaults parameter, it should be + for name, _ in inspect.getmembers(Defaults, lambda x: isinstance(x, property)): + if name == ptb_param.name or "parse_mode" in ptb_param.name: + # mapped_type should not be a tuple since we need to check for equality: + # This can happen when the Defaults parameter is a class, e.g. LinkPreviewOptions + if isinstance(mapped_type, tuple): + mapped_type = mapped_type[1] # We select the ForwardRef + # Assert if it's ODVInput by checking equality: + parsed = ODVInput[mapped_type] + if (ptb_annotation | None) == parsed: # We have to add back None in our annotation + return True + return False + + # 3) HANDLING OTHER TYPES: # Special case for send_* methods where we accept more types than the official API: if ( ptb_param.name in ADDITIONAL_TYPES @@ -447,11 +511,7 @@ def check_param_type( ): mapped_type = mapped_type | ADDITIONAL_TYPES[ptb_param.name] - for (param_name, expected_class), exception_type in EXCEPTIONS.items(): - if ptb_param.name == param_name and is_class is expected_class: - ptb_annotation = exception_type - - # Special case for datetimes + # 4) HANDLING DATETIMES: if ( re.search( r"""([_]+|\b) # check for word boundary or underscore @@ -472,10 +532,18 @@ def check_param_type( # If it's a class, we only accept datetime as the parameter mapped_type = datetime if is_class else mapped_type | datetime - # Final check for the basic types + # RESULTS: ALL OTHER BASIC TYPES- + # Some types are too complicated, so we replace them with a simpler type: + for (param_name, expected_class), exception_type in COMPLEX_TYPES.items(): + if ptb_param.name == param_name and is_class is expected_class: + ptb_annotation = exception_type + + # Final check, if the annotation is a tuple, we need to check if any of the types in the tuple + # match the mapped type. if isinstance(mapped_type, tuple) and any(ptb_annotation == t for t in mapped_type): return True + # If the annotation is not a tuple, we can just check if it's equal to the mapped type. return mapped_type == ptb_annotation diff --git a/tests/test_reaction.py b/tests/test_reaction.py new file mode 100644 index 00000000000..1a152347965 --- /dev/null +++ b/tests/test_reaction.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2023 +# 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 inspect +from copy import deepcopy + +import pytest + +from telegram import ( + BotCommand, + Dice, + ReactionCount, + ReactionType, + ReactionTypeCustomEmoji, + ReactionTypeEmoji, +) +from telegram.constants import ReactionEmoji +from tests.auxil.slots import mro_slots + +ignored = ["self", "api_kwargs"] + + +class RTDefaults: + custom_emoji = "123custom" + normal_emoji = ReactionEmoji.THUMBS_UP + + +def reaction_type_custom_emoji(): + return ReactionTypeCustomEmoji(RTDefaults.custom_emoji) + + +def reaction_type_emoji(): + return ReactionTypeEmoji(RTDefaults.normal_emoji) + + +def make_json_dict(instance: ReactionType, include_optional_args: bool = False) -> dict: + """Used to make the json dict which we use for testing de_json. Similar to iter_args()""" + json_dict = {"type": instance.type} + sig = inspect.signature(instance.__class__.__init__) + + for param in sig.parameters.values(): + if param.name in ignored: # ignore irrelevant params + continue + + val = getattr(instance, param.name) + # Compulsory args- + if param.default is inspect.Parameter.empty: + if hasattr(val, "to_dict"): # convert the user object or any future ones to dict. + val = val.to_dict() + json_dict[param.name] = val + + # If we want to test all args (for de_json)- + # currently not needed, keeping for completeness + elif param.default is not inspect.Parameter.empty and include_optional_args: + json_dict[param.name] = val + return json_dict + + +def iter_args(instance: ReactionType, de_json_inst: ReactionType, include_optional: bool = False): + """ + We accept both the regular instance and de_json created instance and iterate over them for + easy one line testing later one. + """ + yield instance.type, de_json_inst.type # yield this here cause it's not available in sig. + + sig = inspect.signature(instance.__class__.__init__) + for param in sig.parameters.values(): + if param.name in ignored: + continue + inst_at, json_at = getattr(instance, param.name), getattr(de_json_inst, param.name) + if ( + param.default is not inspect.Parameter.empty and include_optional + ) or param.default is inspect.Parameter.empty: + yield inst_at, json_at + + +@pytest.fixture() +def reaction_type(request): + return request.param() + + +@pytest.mark.parametrize( + "reaction_type", + [ + reaction_type_custom_emoji, + reaction_type_emoji, + ], + indirect=True, +) +class TestReactionTypesWithoutRequest: + def test_slot_behaviour(self, reaction_type): + inst = reaction_type + 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_required_args(self, bot, reaction_type): + cls = reaction_type.__class__ + assert cls.de_json(None, bot) is None + + json_dict = make_json_dict(reaction_type) + const_reaction_type = ReactionType.de_json(json_dict, bot) + assert const_reaction_type.api_kwargs == {} + + assert isinstance(const_reaction_type, ReactionType) + assert isinstance(const_reaction_type, cls) + for reaction_type_at, const_reaction_type_at in iter_args( + reaction_type, const_reaction_type + ): + assert reaction_type_at == const_reaction_type_at + + def test_de_json_all_args(self, bot, reaction_type): + json_dict = make_json_dict(reaction_type, include_optional_args=True) + const_reaction_type = ReactionType.de_json(json_dict, bot) + assert const_reaction_type.api_kwargs == {} + + assert isinstance(const_reaction_type, ReactionType) + assert isinstance(const_reaction_type, reaction_type.__class__) + for c_mem_type_at, const_c_mem_at in iter_args(reaction_type, const_reaction_type, True): + assert c_mem_type_at == const_c_mem_at + + def test_de_json_invalid_type(self, bot, reaction_type): + json_dict = {"type": "invalid"} + reaction_type = ReactionType.de_json(json_dict, bot) + + assert type(reaction_type) is ReactionType + assert reaction_type.type == "invalid" + + def test_de_json_subclass(self, reaction_type, bot, chat_id): + """This makes sure that e.g. ReactionTypeEmoji(data, bot) never returns a + ReactionTypeCustomEmoji instance.""" + cls = reaction_type.__class__ + json_dict = make_json_dict(reaction_type, True) + assert type(cls.de_json(json_dict, bot)) is cls + + def test_to_dict(self, reaction_type): + reaction_type_dict = reaction_type.to_dict() + + assert isinstance(reaction_type_dict, dict) + assert reaction_type_dict["type"] == reaction_type.type + if reaction_type.type == ReactionType.EMOJI: + assert reaction_type_dict["emoji"] == reaction_type.emoji + else: + assert reaction_type_dict["custom_emoji_id"] == reaction_type.custom_emoji_id + + for slot in reaction_type.__slots__: # additional verification for the optional args + assert getattr(reaction_type, slot) == reaction_type_dict[slot] + + def test_reaction_type_api_kwargs(self, reaction_type): + json_dict = make_json_dict(reaction_type_custom_emoji()) + json_dict["custom_arg"] = "wuhu" + reaction_type_custom_emoji_instance = ReactionType.de_json(json_dict, None) + assert reaction_type_custom_emoji_instance.api_kwargs == { + "custom_arg": "wuhu", + } + + def test_equality(self, reaction_type): + a = ReactionTypeEmoji(emoji=RTDefaults.normal_emoji) + b = ReactionTypeEmoji(emoji=RTDefaults.normal_emoji) + c = ReactionTypeCustomEmoji(custom_emoji_id=RTDefaults.custom_emoji) + d = ReactionTypeCustomEmoji(custom_emoji_id=RTDefaults.custom_emoji) + e = ReactionTypeEmoji(emoji=ReactionEmoji.RED_HEART) + f = ReactionTypeCustomEmoji(custom_emoji_id="1234custom") + g = deepcopy(a) + h = deepcopy(c) + i = Dice(4, "emoji") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != e + assert hash(a) != hash(e) + + assert a == g + assert hash(a) == hash(g) + + assert a != i + assert hash(a) != hash(i) + + assert c == d + assert hash(c) == hash(d) + + assert c != e + assert hash(c) != hash(e) + + assert c != f + assert hash(c) != hash(f) + + assert c == h + assert hash(c) == hash(h) + + assert c != i + assert hash(c) != hash(i) + + +@pytest.fixture(scope="module") +def reaction_count(): + return ReactionCount( + type=TestReactionCountWithoutRequest.type, + total_count=TestReactionCountWithoutRequest.total_count, + ) + + +class TestReactionCountWithoutRequest: + type = ReactionTypeEmoji(ReactionEmoji.THUMBS_UP) + total_count = 42 + + def test_slot_behaviour(self, reaction_count): + for attr in reaction_count.__slots__: + assert getattr(reaction_count, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(reaction_count)) == len( + set(mro_slots(reaction_count)) + ), "duplicate slot" + + def test_de_json(self, bot): + json_dict = { + "type": self.type.to_dict(), + "total_count": self.total_count, + } + + reaction_count = ReactionCount.de_json(json_dict, bot) + assert reaction_count.api_kwargs == {} + + assert isinstance(reaction_count, ReactionCount) + assert reaction_count.type == self.type + assert reaction_count.type.type == self.type.type + assert reaction_count.type.emoji == self.type.emoji + assert reaction_count.total_count == self.total_count + + assert ReactionCount.de_json(None, bot) is None + + def test_to_dict(self, reaction_count): + reaction_count_dict = reaction_count.to_dict() + + assert isinstance(reaction_count_dict, dict) + assert reaction_count_dict["type"] == reaction_count.type.to_dict() + assert reaction_count_dict["total_count"] == reaction_count.total_count + + def test_equality(self, reaction_count): + a = reaction_count + b = ReactionCount( + type=self.type, + total_count=self.total_count, + ) + c = ReactionCount( + type=self.type, + total_count=self.total_count + 1, + ) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_reply.py b/tests/test_reply.py new file mode 100644 index 00000000000..e79725b5e48 --- /dev/null +++ b/tests/test_reply.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2023 +# 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 datetime as dtm + +import pytest + +from telegram import ( + BotCommand, + Chat, + ExternalReplyInfo, + Giveaway, + LinkPreviewOptions, + MessageEntity, + MessageOriginUser, + ReplyParameters, + TextQuote, + User, +) +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def external_reply_info(): + return ExternalReplyInfo( + origin=TestExternalReplyInfoBase.origin, + chat=TestExternalReplyInfoBase.chat, + message_id=TestExternalReplyInfoBase.message_id, + link_preview_options=TestExternalReplyInfoBase.link_preview_options, + giveaway=TestExternalReplyInfoBase.giveaway, + ) + + +class TestExternalReplyInfoBase: + origin = MessageOriginUser( + dtm.datetime.now(dtm.timezone.utc).replace(microsecond=0), User(1, "user", False) + ) + chat = Chat(1, Chat.SUPERGROUP) + message_id = 123 + link_preview_options = LinkPreviewOptions(True) + giveaway = Giveaway( + (Chat(1, Chat.CHANNEL), Chat(2, Chat.SUPERGROUP)), + dtm.datetime.now(dtm.timezone.utc).replace(microsecond=0), + 1, + ) + + +class TestExternalReplyInfoWithoutRequest(TestExternalReplyInfoBase): + def test_slot_behaviour(self, external_reply_info): + for attr in external_reply_info.__slots__: + assert getattr(external_reply_info, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(external_reply_info)) == len( + set(mro_slots(external_reply_info)) + ), "duplicate slot" + + def test_de_json(self, bot): + json_dict = { + "origin": self.origin.to_dict(), + "chat": self.chat.to_dict(), + "message_id": self.message_id, + "link_preview_options": self.link_preview_options.to_dict(), + "giveaway": self.giveaway.to_dict(), + } + + external_reply_info = ExternalReplyInfo.de_json(json_dict, bot) + assert external_reply_info.api_kwargs == {} + + assert external_reply_info.origin == self.origin + assert external_reply_info.chat == self.chat + assert external_reply_info.message_id == self.message_id + assert external_reply_info.link_preview_options == self.link_preview_options + assert external_reply_info.giveaway == self.giveaway + + assert ExternalReplyInfo.de_json(None, bot) is None + + def test_to_dict(self, external_reply_info): + ext_reply_info_dict = external_reply_info.to_dict() + + assert isinstance(ext_reply_info_dict, dict) + assert ext_reply_info_dict["origin"] == self.origin.to_dict() + assert ext_reply_info_dict["chat"] == self.chat.to_dict() + assert ext_reply_info_dict["message_id"] == self.message_id + assert ext_reply_info_dict["link_preview_options"] == self.link_preview_options.to_dict() + assert ext_reply_info_dict["giveaway"] == self.giveaway.to_dict() + + def test_equality(self, external_reply_info): + a = external_reply_info + b = ExternalReplyInfo(origin=self.origin) + c = ExternalReplyInfo( + origin=MessageOriginUser(dtm.datetime.utcnow(), User(2, "user", False)) + ) + + d = BotCommand("start", "description") + + 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(scope="module") +def text_quote(): + return TextQuote( + text=TestTextQuoteBase.text, + position=TestTextQuoteBase.position, + entities=TestTextQuoteBase.entities, + is_manual=TestTextQuoteBase.is_manual, + ) + + +class TestTextQuoteBase: + text = "text" + position = 1 + entities = [ + MessageEntity(MessageEntity.MENTION, 1, 2), + MessageEntity(MessageEntity.EMAIL, 3, 4), + ] + is_manual = True + + +class TestTextQuoteWithoutRequest(TestTextQuoteBase): + def test_slot_behaviour(self, text_quote): + for attr in text_quote.__slots__: + assert getattr(text_quote, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(text_quote)) == len(set(mro_slots(text_quote))), "duplicate slot" + + def test_de_json(self, bot): + json_dict = { + "text": self.text, + "position": self.position, + "entities": [entity.to_dict() for entity in self.entities], + "is_manual": self.is_manual, + } + + text_quote = TextQuote.de_json(json_dict, bot) + assert text_quote.api_kwargs == {} + + assert text_quote.text == self.text + assert text_quote.position == self.position + assert text_quote.entities == tuple(self.entities) + assert text_quote.is_manual == self.is_manual + + assert TextQuote.de_json(None, bot) is None + + def test_to_dict(self, text_quote): + text_quote_dict = text_quote.to_dict() + + assert isinstance(text_quote_dict, dict) + assert text_quote_dict["text"] == self.text + assert text_quote_dict["position"] == self.position + assert text_quote_dict["entities"] == [entity.to_dict() for entity in self.entities] + assert text_quote_dict["is_manual"] == self.is_manual + + def test_equality(self, text_quote): + a = text_quote + b = TextQuote(text=self.text, position=self.position) + c = TextQuote(text="foo", position=self.position) + d = TextQuote(text=self.text, position=7) + + e = BotCommand("start", "description") + + 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) + + +@pytest.fixture(scope="module") +def reply_parameters(): + return ReplyParameters( + message_id=TestReplyParametersBase.message_id, + chat_id=TestReplyParametersBase.chat_id, + allow_sending_without_reply=TestReplyParametersBase.allow_sending_without_reply, + quote=TestReplyParametersBase.quote, + quote_parse_mode=TestReplyParametersBase.quote_parse_mode, + quote_entities=TestReplyParametersBase.quote_entities, + quote_position=TestReplyParametersBase.quote_position, + ) + + +class TestReplyParametersBase: + message_id = 123 + chat_id = 456 + allow_sending_without_reply = True + quote = "foo" + quote_parse_mode = "html" + quote_entities = [ + MessageEntity(MessageEntity.MENTION, 1, 2), + MessageEntity(MessageEntity.EMAIL, 3, 4), + ] + quote_position = 5 + + +class TestReplyParametersWithoutRequest(TestReplyParametersBase): + def test_slot_behaviour(self, reply_parameters): + for attr in reply_parameters.__slots__: + assert getattr(reply_parameters, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(reply_parameters)) == len( + set(mro_slots(reply_parameters)) + ), "duplicate slot" + + def test_de_json(self, bot): + json_dict = { + "message_id": self.message_id, + "chat_id": self.chat_id, + "allow_sending_without_reply": self.allow_sending_without_reply, + "quote": self.quote, + "quote_parse_mode": self.quote_parse_mode, + "quote_entities": [entity.to_dict() for entity in self.quote_entities], + "quote_position": self.quote_position, + } + + reply_parameters = ReplyParameters.de_json(json_dict, bot) + assert reply_parameters.api_kwargs == {} + + assert reply_parameters.message_id == self.message_id + assert reply_parameters.chat_id == self.chat_id + assert reply_parameters.allow_sending_without_reply == self.allow_sending_without_reply + assert reply_parameters.quote == self.quote + assert reply_parameters.quote_parse_mode == self.quote_parse_mode + assert reply_parameters.quote_entities == tuple(self.quote_entities) + assert reply_parameters.quote_position == self.quote_position + + assert ReplyParameters.de_json(None, bot) is None + + def test_to_dict(self, reply_parameters): + reply_parameters_dict = reply_parameters.to_dict() + + assert isinstance(reply_parameters_dict, dict) + assert reply_parameters_dict["message_id"] == self.message_id + assert reply_parameters_dict["chat_id"] == self.chat_id + assert ( + reply_parameters_dict["allow_sending_without_reply"] + == self.allow_sending_without_reply + ) + assert reply_parameters_dict["quote"] == self.quote + assert reply_parameters_dict["quote_parse_mode"] == self.quote_parse_mode + assert reply_parameters_dict["quote_entities"] == [ + entity.to_dict() for entity in self.quote_entities + ] + assert reply_parameters_dict["quote_position"] == self.quote_position + + def test_equality(self, reply_parameters): + a = reply_parameters + b = ReplyParameters(message_id=self.message_id) + c = ReplyParameters(message_id=7) + + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_shared.py b/tests/test_shared.py index 8b57a85d701..1ea98ac5d20 100644 --- a/tests/test_shared.py +++ b/tests/test_shared.py @@ -16,27 +16,73 @@ # # 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 inspect import pytest -from telegram import ChatShared, UserShared +from telegram import ChatShared, UserShared, UsersShared +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @pytest.fixture(scope="class") def user_shared(): - return UserShared( - TestUserSharedBase.request_id, - TestUserSharedBase.user_id, - ) + return UserShared(TestUsersSharedBase.request_id, TestUsersSharedBase.user_id) + +@pytest.fixture(scope="class") +def users_shared(): + return UsersShared(TestUsersSharedBase.request_id, TestUsersSharedBase.user_ids) -class TestUserSharedBase: + +class TestUsersSharedBase: request_id = 789 user_id = 101112 + user_ids = (user_id, 101113) + + +class TestUsersSharedWithoutRequest(TestUsersSharedBase): + def test_slot_behaviour(self, users_shared): + for attr in users_shared.__slots__: + assert getattr(users_shared, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(users_shared)) == len(set(mro_slots(users_shared))), "duplicate slot" + + def test_to_dict(self, users_shared): + users_shared_dict = users_shared.to_dict() + + assert isinstance(users_shared_dict, dict) + assert users_shared_dict["request_id"] == self.request_id + assert users_shared_dict["user_ids"] == list(self.user_ids) + + def test_de_json(self, bot): + json_dict = { + "request_id": self.request_id, + "user_ids": self.user_ids, + } + users_shared = UsersShared.de_json(json_dict, bot) + assert users_shared.api_kwargs == {} + + assert users_shared.request_id == self.request_id + assert users_shared.user_ids == tuple(self.user_ids) + + def test_equality(self): + a = UsersShared(self.request_id, self.user_ids) + b = UsersShared(self.request_id, self.user_ids) + c = UsersShared(1, self.user_ids) + d = UsersShared(self.request_id, [1, 2]) + + 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) -class TestUserSharedWithoutRequest(TestUserSharedBase): +class TestUserSharedWithoutRequest(TestUsersSharedBase): def test_slot_behaviour(self, user_shared): for attr in user_shared.__slots__: assert getattr(user_shared, attr, "err") != "err", f"got extra slot '{attr}'" @@ -47,7 +93,7 @@ def test_to_dict(self, user_shared): assert isinstance(user_shared_dict, dict) assert user_shared_dict["request_id"] == self.request_id - assert user_shared_dict["user_id"] == self.user_id + assert user_shared_dict["user_ids"] == [self.user_id] def test_de_json(self, bot): json_dict = { @@ -59,6 +105,34 @@ def test_de_json(self, bot): assert user_shared.request_id == self.request_id assert user_shared.user_id == self.user_id + assert user_shared.user_ids == (self.user_id,) + + def test_signature(self): + user_signature = inspect.signature(UserShared) + users_signature = inspect.signature(UsersShared) + + assert user_signature.return_annotation == users_signature.return_annotation + + for name, parameter in user_signature.parameters.items(): + if name not in users_signature.parameters: + assert name == "user_id" + else: + assert parameter.annotation == users_signature.parameters[name].annotation + + assert set(users_signature.parameters) - set(user_signature.parameters) == {"user_ids"} + + def test_deprecation_warnings(self): + with pytest.warns( + PTBDeprecationWarning, match="'UserShared' was renamed to 'UsersShared'" + ) as record: + user_shared = UserShared(request_id=1, user_id=1) + + assert record[0].filename == __file__, "wrong stacklevel" + + with pytest.warns(PTBDeprecationWarning, match="'user_id' to 'user_ids'") as record: + user_shared.user_id + + assert record[0].filename == __file__, "wrong stacklevel" def test_equality(self): a = UserShared(self.request_id, self.user_id) diff --git a/tests/test_telegramobject.py b/tests/test_telegramobject.py index 6db745e026b..48101724418 100644 --- a/tests/test_telegramobject.py +++ b/tests/test_telegramobject.py @@ -27,6 +27,7 @@ import pytest from telegram import Bot, BotCommand, Chat, Message, PhotoSize, TelegramObject, User +from telegram._utils.defaultvalue import DEFAULT_FALSE, DEFAULT_NONE, DefaultValue from telegram.ext import PicklePersistence from telegram.warnings import PTBUserWarning from tests.auxil.files import data_file @@ -206,6 +207,18 @@ def __init__(self): assert isinstance(to_dict_recurse["subclass"], dict) assert to_dict_recurse["subclass"]["recursive"] == "recursive" + def test_to_dict_default_value(self): + class SubClass(TelegramObject): + def __init__(self): + super().__init__() + self.default_none = DEFAULT_NONE + self.default_false = DEFAULT_FALSE + + to = SubClass() + to_dict = to.to_dict() + assert "default_none" not in to_dict + assert to_dict["default_false"] is False + def test_slot_behaviour(self): inst = TelegramObject() for attr in inst.__slots__: @@ -280,6 +293,7 @@ def test_pickle(self, bot): from_user=user, text="foobar", photo=[photo], + animation=DEFAULT_NONE, api_kwargs={"api": "kwargs"}, ) msg.set_bot(bot) @@ -295,6 +309,8 @@ def test_pickle(self, bot): assert unpickled.from_user == user assert unpickled.date == date, f"{unpickled.date} != {date}" assert unpickled.photo[0] == photo + assert isinstance(unpickled.animation, DefaultValue) + assert unpickled.animation.value is None assert isinstance(unpickled.api_kwargs, MappingProxyType) assert unpickled.api_kwargs == {"api": "kwargs"} diff --git a/tests/test_update.py b/tests/test_update.py index 7068d4cb534..0420df59fa0 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -24,21 +24,31 @@ from telegram import ( CallbackQuery, Chat, + ChatBoost, + ChatBoostRemoved, + ChatBoostSourcePremium, + ChatBoostUpdated, ChatJoinRequest, ChatMemberOwner, ChatMemberUpdated, ChosenInlineResult, + InaccessibleMessage, InlineQuery, Message, + MessageReactionCountUpdated, + MessageReactionUpdated, Poll, PollAnswer, PollOption, PreCheckoutQuery, + ReactionCount, + ReactionTypeEmoji, ShippingQuery, Update, User, ) from telegram._utils.datetime import from_timestamp +from telegram.warnings import PTBUserWarning from tests.auxil.slots import mro_slots message = Message(1, datetime.utcnow(), Chat(1, ""), from_user=User(1, "", False), text="Text") @@ -59,6 +69,41 @@ bio="bio", ) +chat_boost = ChatBoostUpdated( + chat=Chat(1, "priv"), + boost=ChatBoost( + "1", + from_timestamp(int(time.time())), + from_timestamp(int(time.time())), + ChatBoostSourcePremium(User(1, "", False)), + ), +) + +removed_chat_boost = ChatBoostRemoved( + Chat(1, "private"), + "2", + from_timestamp(int(time.time())), + ChatBoostSourcePremium(User(1, "name", False)), +) + +message_reaction = MessageReactionUpdated( + chat=Chat(1, "chat"), + message_id=1, + date=from_timestamp(int(time.time())), + old_reaction=(ReactionTypeEmoji("👍"),), + new_reaction=(ReactionTypeEmoji("👍"),), + user=User(1, "name", False), +) + + +message_reaction_count = MessageReactionCountUpdated( + chat=Chat(1, "chat"), + message_id=1, + date=from_timestamp(int(time.time())), + reactions=(ReactionCount(ReactionTypeEmoji("👍"), 1),), +) + + params = [ {"message": message}, {"edited_message": message}, @@ -85,6 +130,10 @@ {"my_chat_member": chat_member_updated}, {"chat_member": chat_member_updated}, {"chat_join_request": chat_join_request}, + {"chat_boost": chat_boost}, + {"removed_chat_boost": removed_chat_boost}, + {"message_reaction": message_reaction}, + {"message_reaction_count": message_reaction_count}, # Must be last to conform with `ids` below! {"callback_query": CallbackQuery(1, User(1, "", False), "chat")}, ] @@ -104,6 +153,10 @@ "my_chat_member", "chat_member", "chat_join_request", + "chat_boost", + "removed_chat_boost", + "message_reaction", + "message_reaction_count", ) ids = (*all_types, "callback_query_without_message") @@ -200,6 +253,9 @@ def test_effective_user(self, update): update.channel_post is not None or update.edited_channel_post is not None or update.poll is not None + or update.chat_boost is not None + or update.removed_chat_boost is not None + or update.message_reaction_count is not None ): assert user.id == 1 else: @@ -219,7 +275,29 @@ def test_effective_message(self, update): or update.my_chat_member is not None or update.chat_member is not None or update.chat_join_request is not None + or update.chat_boost is not None + or update.removed_chat_boost is not None + or update.message_reaction is not None + or update.message_reaction_count is not None ): assert eff_message.message_id == message.message_id else: assert eff_message is None + + def test_effective_message_inaccessible(self): + update = Update( + update_id=1, + callback_query=CallbackQuery( + "id", + User(1, "", False), + "chat", + message=InaccessibleMessage(message_id=1, chat=Chat(1, "")), + ), + ) + with pytest.warns( + PTBUserWarning, + match="update.callback_query` is not `None`, but of type `InaccessibleMessage`", + ) as record: + assert update.effective_message is None + + assert record[0].filename == __file__ diff --git a/tests/test_user.py b/tests/test_user.py index e21b5443d31..cdec4f070c9 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -454,6 +454,23 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(user.get_bot(), "copy_message", make_assertion) assert await user.copy_message(chat_id="chat_id", message_id="message_id") + async def test_instance_method_get_user_chat_boosts(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + chat_id = kwargs["chat_id"] == "chat_id" + user_id = kwargs["user_id"] == user.id + return chat_id and user_id + + assert check_shortcut_signature( + User.get_chat_boosts, Bot.get_user_chat_boosts, ["user_id"], [] + ) + assert await check_shortcut_call( + user.get_chat_boosts, user.get_bot(), "get_user_chat_boosts" + ) + assert await check_defaults_handling(user.get_chat_boosts, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "get_user_chat_boosts", make_assertion) + assert await user.get_chat_boosts(chat_id="chat_id") + async def test_instance_method_get_menu_button(self, monkeypatch, user): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == user.id @@ -562,3 +579,119 @@ async def test_mention_markdown_v2(self, user): "the\\{name\\>\u2022", user.id ) assert user.mention_markdown_v2(user.username) == expected.format(user.username, user.id) + + async def test_delete_message(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return kwargs["chat_id"] == user.id and kwargs["message_id"] == 42 + + assert check_shortcut_signature(user.delete_message, Bot.delete_message, ["chat_id"], []) + assert await check_shortcut_call(user.delete_message, user.get_bot(), "delete_message") + assert await check_defaults_handling(user.delete_message, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "delete_message", make_assertion) + assert await user.delete_message(message_id=42) + + async def test_delete_messages(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return kwargs["chat_id"] == user.id and kwargs["message_ids"] == (42, 43) + + assert check_shortcut_signature(user.delete_messages, Bot.delete_messages, ["chat_id"], []) + assert await check_shortcut_call(user.delete_messages, user.get_bot(), "delete_messages") + assert await check_defaults_handling(user.delete_messages, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "delete_messages", make_assertion) + assert await user.delete_messages(message_ids=(42, 43)) + + async def test_instance_method_send_copies(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + from_chat_id = kwargs["from_chat_id"] == "test_copies" + message_ids = kwargs["message_ids"] == (42, 43) + user_id = kwargs["chat_id"] == user.id + return from_chat_id and message_ids and user_id + + assert check_shortcut_signature(user.send_copies, Bot.copy_messages, ["chat_id"], []) + assert await check_shortcut_call(user.send_copies, user.get_bot(), "copy_messages") + assert await check_defaults_handling(user.send_copies, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "copy_messages", make_assertion) + assert await user.send_copies(from_chat_id="test_copies", message_ids=(42, 43)) + + async def test_instance_method_copy_messages(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + from_chat_id = kwargs["from_chat_id"] == user.id + message_ids = kwargs["message_ids"] == (42, 43) + user_id = kwargs["chat_id"] == "test_copies" + return from_chat_id and message_ids and user_id + + assert check_shortcut_signature( + user.copy_messages, Bot.copy_messages, ["from_chat_id"], [] + ) + assert await check_shortcut_call(user.copy_messages, user.get_bot(), "copy_messages") + assert await check_defaults_handling(user.copy_messages, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "copy_messages", make_assertion) + assert await user.copy_messages(chat_id="test_copies", message_ids=(42, 43)) + + async def test_instance_method_forward_from(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + user_id = kwargs["chat_id"] == user.id + message_id = kwargs["message_id"] == 42 + from_chat_id = kwargs["from_chat_id"] == "test_forward" + return from_chat_id and message_id and user_id + + assert check_shortcut_signature(user.forward_from, Bot.forward_message, ["chat_id"], []) + assert await check_shortcut_call(user.forward_from, user.get_bot(), "forward_message") + assert await check_defaults_handling(user.forward_from, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "forward_message", make_assertion) + assert await user.forward_from(from_chat_id="test_forward", message_id=42) + + async def test_instance_method_forward_to(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + from_chat_id = kwargs["from_chat_id"] == user.id + message_id = kwargs["message_id"] == 42 + user_id = kwargs["chat_id"] == "test_forward" + return from_chat_id and message_id and user_id + + assert check_shortcut_signature(user.forward_to, Bot.forward_message, ["from_chat_id"], []) + assert await check_shortcut_call(user.forward_to, user.get_bot(), "forward_message") + assert await check_defaults_handling(user.forward_to, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "forward_message", make_assertion) + assert await user.forward_to(chat_id="test_forward", message_id=42) + + async def test_instance_method_forward_messages_from(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + user_id = kwargs["chat_id"] == user.id + message_ids = kwargs["message_ids"] == (42, 43) + from_chat_id = kwargs["from_chat_id"] == "test_forwards" + return from_chat_id and message_ids and user_id + + assert check_shortcut_signature( + user.forward_messages_from, Bot.forward_messages, ["chat_id"], [] + ) + assert await check_shortcut_call( + user.forward_messages_from, user.get_bot(), "forward_messages" + ) + assert await check_defaults_handling(user.forward_messages_from, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "forward_messages", make_assertion) + assert await user.forward_messages_from(from_chat_id="test_forwards", message_ids=(42, 43)) + + async def test_instance_method_forward_messages_to(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + from_chat_id = kwargs["from_chat_id"] == user.id + message_ids = kwargs["message_ids"] == (42, 43) + user_id = kwargs["chat_id"] == "test_forwards" + return from_chat_id and message_ids and user_id + + assert check_shortcut_signature( + user.forward_messages_to, Bot.forward_messages, ["from_chat_id"], [] + ) + assert await check_shortcut_call( + user.forward_messages_to, user.get_bot(), "forward_messages" + ) + assert await check_defaults_handling(user.forward_messages_to, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "forward_messages", make_assertion) + assert await user.forward_messages_to(chat_id="test_forwards", message_ids=(42, 43))