From 0cceafcab3aa7354e3b4008321fbc92940b70e06 Mon Sep 17 00:00:00 2001 From: Poolitzer Date: Sun, 15 Mar 2026 07:58:30 +0100 Subject: [PATCH] Full support for Bot API 9.5 (#5155) Co-authored-by: Harshil <37377066+harshil21@users.noreply.github.com> Co-authored-by: OuYoung <212045739+ouyooung@users.noreply.github.com> Co-authored-by: Hethon <65696516+hethon@users.noreply.github.com> Co-authored-by: Abdelrahman Elkheir <90580077+aelkheir@users.noreply.github.com> --- README.rst | 4 +- .../5155.J9c3dKTpfBJ3TkZy3ShDXN.toml | 5 ++ docs/source/inclusions/bot_methods.rst | 2 + src/telegram/_bot.py | 62 +++++++++++++- src/telegram/_chat.py | 37 +++++++++ src/telegram/_chatadministratorrights.py | 21 ++++- src/telegram/_chatmember.py | 44 +++++++++- src/telegram/_chatpermissions.py | 23 +++++- src/telegram/_message.py | 41 ++++++++-- src/telegram/_messageentity.py | 55 ++++++++++++- src/telegram/_user.py | 35 ++++++++ src/telegram/constants.py | 81 ++++++++++++++++++- src/telegram/ext/_extbot.py | 27 +++++++ tests/test_bot.py | 15 ++++ tests/test_chat.py | 19 +++++ tests/test_chatadministratorrights.py | 5 ++ tests/test_chatmember.py | 19 +++++ tests/test_chatpermissions.py | 7 ++ tests/test_constants.py | 1 + tests/test_message.py | 44 ++++++++-- tests/test_messageentity.py | 27 ++++++- tests/test_user.py | 19 +++++ 22 files changed, 569 insertions(+), 24 deletions(-) create mode 100644 changes/unreleased/5155.J9c3dKTpfBJ3TkZy3ShDXN.toml diff --git a/README.rst b/README.rst index be50d06e9..bfdedc88a 100644 --- a/README.rst +++ b/README.rst @@ -11,7 +11,7 @@ :target: https://pypi.org/project/python-telegram-bot/ :alt: Supported Python versions -.. image:: https://img.shields.io/badge/Bot%20API-9.4-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-9.5-blue?logo=telegram :target: https://core.telegram.org/bots/api-changelog :alt: Supported Bot API version @@ -77,7 +77,7 @@ After installing_ the library, be sure to check out the section on `working with Telegram API support ~~~~~~~~~~~~~~~~~~~~ -All types and methods of the Telegram Bot API **9.4** are natively supported by this library. +All types and methods of the Telegram Bot API **9.5** are natively supported by this library. In addition, Bot API functionality not yet natively included can still be used as described `in our wiki `_. Notable Features diff --git a/changes/unreleased/5155.J9c3dKTpfBJ3TkZy3ShDXN.toml b/changes/unreleased/5155.J9c3dKTpfBJ3TkZy3ShDXN.toml new file mode 100644 index 000000000..faf183ec7 --- /dev/null +++ b/changes/unreleased/5155.J9c3dKTpfBJ3TkZy3ShDXN.toml @@ -0,0 +1,5 @@ +features = "Full support for Bot API 9.5" +[[pull_requests]] +uid = "5155" +author_uids = ["Poolitzer"] +closes_threads = [] diff --git a/docs/source/inclusions/bot_methods.rst b/docs/source/inclusions/bot_methods.rst index 2d9ed2c71..c7b178dd4 100644 --- a/docs/source/inclusions/bot_methods.rst +++ b/docs/source/inclusions/bot_methods.rst @@ -183,6 +183,8 @@ - Used for getting the list of boosts added to a chat * - :meth:`~telegram.Bot.leave_chat` - Used for leaving a chat + * - :meth:`~telegram.Bot.set_chat_member_tag` + - Used for setting the tag of a chat member .. raw:: html diff --git a/src/telegram/_bot.py b/src/telegram/_bot.py index f9c2b299d..0e3544990 100644 --- a/src/telegram/_bot.py +++ b/src/telegram/_bot.py @@ -1218,10 +1218,14 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]): api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to stream a partial message to a user while the message is being - generated; supported only for bots with forum topic mode enabled. + generated. .. versionadded:: 22.6 + .. versionchanged:: NEXT.VERSION + Now all bots can use this method. + + Args: chat_id (:obj:`int`): Unique identifier for the target private chat. draft_id (:obj:`int`): Unique identifier of the message draft; must be non-zero. @@ -5932,6 +5936,7 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]): can_edit_stories: bool | None = None, can_delete_stories: bool | None = None, can_manage_direct_messages: bool | None = None, + can_manage_tags: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -6007,6 +6012,10 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]): posts; for channels only .. versionadded:: 22.4 + can_manage_tags (:obj:`bool`, optional): Pass :obj:`True`, if the administrator can + edit the tags of regular members; for groups and supergroups only. + + .. versionadded:: NEXT.VERSION Returns: :obj:`bool`: On success, :obj:`True` is returned. @@ -6034,6 +6043,7 @@ class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]): "can_edit_stories": can_edit_stories, "can_delete_stories": can_delete_stories, "can_manage_direct_messages": can_manage_direct_messages, + "can_manage_tags": can_manage_tags, } return await self._post( @@ -12019,6 +12029,54 @@ CHAT_ACTIVITY_TIMEOUT` seconds. return UserProfileAudios.de_json(result, self) + async def set_chat_member_tag( + self, + chat_id: int | str, + user_id: int, + tag: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Use this method to set a tag for a regular member in a group or a supergroup. The bot must + be an administrator in the chat for this to work and must have the + :attr:`~telegram.ChatMemberAdministrator.can_manage_tags` administrator right. + + .. versionadded:: NEXT.VERSION + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_group| + user_id (:obj:`int`): Unique identifier of the target user. + tag (:obj:`str`, optional): New tag for the member; + 0-:tg-const:`telegram.constants.TagLimit.MAX_TAG_LENGTH` characters, emoji are not + allowed. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "chat_id": chat_id, + "user_id": user_id, + "tag": tag, + } + + return await self._post( + "setChatMemberTag", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """See :meth:`telegram.TelegramObject.to_dict`.""" data: JSONDict = {"id": self.id, "username": self.username, "first_name": self.first_name} @@ -12357,3 +12415,5 @@ CHAT_ACTIVITY_TIMEOUT` seconds. """Alias for :meth:`set_my_profile_photo`""" getUserProfileAudios = get_user_profile_audios """Alias for :meth:`get_user_profile_audios`""" + setChatMemberTag = set_chat_member_tag + """Alias for :meth:`set_chat_member_tag`""" diff --git a/src/telegram/_chat.py b/src/telegram/_chat.py index 927da4d8c..27e213f10 100644 --- a/src/telegram/_chat.py +++ b/src/telegram/_chat.py @@ -619,6 +619,7 @@ class _ChatBase(TelegramObject): can_edit_stories: bool | None = None, can_delete_stories: bool | None = None, can_manage_direct_messages: bool | None = None, + can_manage_tags: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -670,6 +671,7 @@ class _ChatBase(TelegramObject): can_edit_stories=can_edit_stories, can_delete_stories=can_delete_stories, can_manage_direct_messages=can_manage_direct_messages, + can_manage_tags=can_manage_tags, ) async def restrict_member( @@ -3995,6 +3997,41 @@ class _ChatBase(TelegramObject): api_kwargs=api_kwargs, ) + async def set_chat_member_tag( + self, + user_id: int, + tag: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Shortcut for:: + + await bot.set_chat_member_tag(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.set_chat_member_tag`. + + .. versionadded:: NEXT.VERSION + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().set_chat_member_tag( + chat_id=self.id, + user_id=user_id, + tag=tag, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + class Chat(_ChatBase): """This object represents a chat. diff --git a/src/telegram/_chatadministratorrights.py b/src/telegram/_chatadministratorrights.py index 31fbda4b7..909cf2448 100644 --- a/src/telegram/_chatadministratorrights.py +++ b/src/telegram/_chatadministratorrights.py @@ -31,7 +31,8 @@ class ChatAdministratorRights(TelegramObject): :attr:`can_promote_members`, :attr:`can_change_info`, :attr:`can_invite_users`, :attr:`can_post_messages`, :attr:`can_edit_messages`, :attr:`can_pin_messages`, :attr:`can_manage_topics`, :attr:`can_post_stories`, :attr:`can_delete_stories`, - :attr:`can_edit_stories` and :attr:`can_manage_direct_messages` are equal. + :attr:`can_edit_stories`, :attr:`can_manage_direct_messages` and :attr:`can_manage_tags` are + equal. .. versionadded:: 20.0 @@ -52,6 +53,10 @@ class ChatAdministratorRights(TelegramObject): :attr:`can_manage_direct_messages` is considered as well when comparing objects of this type in terms of equality. + .. versionchanged:: NEXT.VERSION + :attr:`can_manage_tags` is considered as well when comparing objects of this type in terms + of equality. + Args: is_anonymous (:obj:`bool`): :obj:`True`, if the user's presence in the chat is hidden. can_manage_chat (:obj:`bool`): :obj:`True`, if the administrator can access the chat event @@ -104,6 +109,11 @@ class ChatAdministratorRights(TelegramObject): manage direct messages of the channel and decline suggested posts; for channels only. .. versionadded:: 22.4 + can_manage_tags (:obj:`bool`, optional): :obj:`True`, if the administrator can edit the + tags of regular members; for groups and supergroups only. If omitted defaults to the + value of :attr:`can_pin_messages`. + + .. versionadded:: NEXT.VERSION Attributes: is_anonymous (:obj:`bool`): :obj:`True`, if the user's presence in the chat is hidden. @@ -157,6 +167,11 @@ class ChatAdministratorRights(TelegramObject): manage direct messages of the channel and decline suggested posts; for channels only. .. versionadded:: 22.4 + can_manage_tags (:obj:`bool`): Optional. :obj:`True`, if the administrator can edit the + tags of regular members; for groups and supergroups only. If omitted defaults to the + value of :attr:`can_pin_messages`. + + .. versionadded:: NEXT.VERSION """ __slots__ = ( @@ -168,6 +183,7 @@ class ChatAdministratorRights(TelegramObject): "can_invite_users", "can_manage_chat", "can_manage_direct_messages", + "can_manage_tags", "can_manage_topics", "can_manage_video_chats", "can_pin_messages", @@ -196,6 +212,7 @@ class ChatAdministratorRights(TelegramObject): can_pin_messages: bool | None = None, can_manage_topics: bool | None = None, can_manage_direct_messages: bool | None = None, + can_manage_tags: bool | None = None, *, api_kwargs: JSONDict | None = None, ) -> None: @@ -218,6 +235,7 @@ class ChatAdministratorRights(TelegramObject): self.can_pin_messages: bool | None = can_pin_messages self.can_manage_topics: bool | None = can_manage_topics self.can_manage_direct_messages: bool | None = can_manage_direct_messages + self.can_manage_tags: bool | None = can_manage_tags self._id_attrs = ( self.is_anonymous, @@ -236,6 +254,7 @@ class ChatAdministratorRights(TelegramObject): self.can_edit_stories, self.can_delete_stories, self.can_manage_direct_messages, + self.can_manage_tags, ) self._freeze() diff --git a/src/telegram/_chatmember.py b/src/telegram/_chatmember.py index 42323ea2c..f5129b509 100644 --- a/src/telegram/_chatmember.py +++ b/src/telegram/_chatmember.py @@ -257,6 +257,11 @@ class ChatMemberAdministrator(ChatMember): manage direct messages of the channel and decline suggested posts; for channels only. .. versionadded:: 22.4 + can_manage_tags (:obj:`bool`, optional): :obj:`True`, if the administrator can edit the + tags of regular members; for groups and supergroups only. If omitted defaults to the + value of :attr:`can_pin_messages`. + + .. versionadded:: NEXT.VERSION Attributes: status (:obj:`str`): The member's status in the chat, @@ -321,6 +326,11 @@ class ChatMemberAdministrator(ChatMember): manage direct messages of the channel and decline suggested posts; for channels only. .. versionadded:: 22.4 + can_manage_tags (:obj:`bool`): Optional. :obj:`True`, if the administrator can edit the + tags of regular members; for groups and supergroups only. If omitted defaults to the + value of :attr:`can_pin_messages`. + + .. versionadded:: NEXT.VERSION """ __slots__ = ( @@ -333,6 +343,7 @@ class ChatMemberAdministrator(ChatMember): "can_invite_users", "can_manage_chat", "can_manage_direct_messages", + "can_manage_tags", "can_manage_topics", "can_manage_video_chats", "can_pin_messages", @@ -365,6 +376,7 @@ class ChatMemberAdministrator(ChatMember): can_manage_topics: bool | None = None, custom_title: str | None = None, can_manage_direct_messages: bool | None = None, + can_manage_tags: bool | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -389,6 +401,7 @@ class ChatMemberAdministrator(ChatMember): self.can_manage_topics: bool | None = can_manage_topics self.custom_title: str | None = custom_title self.can_manage_direct_messages: bool | None = can_manage_direct_messages + self.can_manage_tags: bool | None = can_manage_tags class ChatMemberMember(ChatMember): @@ -404,6 +417,9 @@ class ChatMemberMember(ChatMember): expire. .. versionadded:: 21.5 + tag (:obj:`str`, optional): Tag of the member. + + .. versionadded:: NEXT.VERSION Attributes: status (:obj:`str`): The member's status in the chat, @@ -413,21 +429,29 @@ class ChatMemberMember(ChatMember): expire. .. versionadded:: 21.5 + tag (:obj:`str`): Optional. Tag of the member. + + .. versionadded:: NEXT.VERSION """ - __slots__ = ("until_date",) + __slots__ = ( + "tag", + "until_date", + ) def __init__( self, user: User, until_date: dtm.datetime | None = None, + tag: str | None = None, *, api_kwargs: JSONDict | None = None, ): super().__init__(status=ChatMember.MEMBER, user=user, api_kwargs=api_kwargs) with self._unfrozen(): self.until_date: dtm.datetime | None = until_date + self.tag: str | None = tag class ChatMemberRestricted(ChatMember): @@ -490,6 +514,12 @@ class ChatMemberRestricted(ChatMember): notes. .. versionadded:: 20.1 + can_edit_tag (:obj:`bool`): :obj:`True`, if the user is allowed to edit their own tag. + + .. versionadded:: NEXT.VERSION + tag (:obj:`str`, optional): Tag of the member. + + .. versionadded:: NEXT.VERSION Attributes: status (:obj:`str`): The member's status in the chat, @@ -540,12 +570,19 @@ class ChatMemberRestricted(ChatMember): notes. .. versionadded:: 20.1 + can_edit_tag (:obj:`bool`): :obj:`True`, if the user is allowed to edit their own tag. + + .. versionadded:: NEXT.VERSION + tag (:obj:`str`): Optional. Tag of the member. + + .. versionadded:: NEXT.VERSION """ __slots__ = ( "can_add_web_page_previews", "can_change_info", + "can_edit_tag", "can_invite_users", "can_manage_topics", "can_pin_messages", @@ -559,6 +596,7 @@ class ChatMemberRestricted(ChatMember): "can_send_videos", "can_send_voice_notes", "is_member", + "tag", "until_date", ) @@ -581,6 +619,8 @@ class ChatMemberRestricted(ChatMember): can_send_videos: bool, can_send_video_notes: bool, can_send_voice_notes: bool, + can_edit_tag: bool, + tag: str | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -602,6 +642,8 @@ class ChatMemberRestricted(ChatMember): self.can_send_videos: bool = can_send_videos self.can_send_video_notes: bool = can_send_video_notes self.can_send_voice_notes: bool = can_send_voice_notes + self.can_edit_tag: bool = can_edit_tag + self.tag: str | None = tag class ChatMemberLeft(ChatMember): diff --git a/src/telegram/_chatpermissions.py b/src/telegram/_chatpermissions.py index cfea9129d..ad04c826d 100644 --- a/src/telegram/_chatpermissions.py +++ b/src/telegram/_chatpermissions.py @@ -35,8 +35,8 @@ class ChatPermissions(TelegramObject): :attr:`can_send_polls`, :attr:`can_send_other_messages`, :attr:`can_add_web_page_previews`, :attr:`can_change_info`, :attr:`can_invite_users`, :attr:`can_pin_messages`, :attr:`can_send_audios`, :attr:`can_send_documents`, :attr:`can_send_photos`, - :attr:`can_send_videos`, :attr:`can_send_video_notes`, :attr:`can_send_voice_notes`, and - :attr:`can_manage_topics` are equal. + :attr:`can_send_videos`, :attr:`can_send_video_notes`, :attr:`can_send_voice_notes`, + :attr:`can_manage_topics` and :attr:`can_edit_tag` are equal. .. versionchanged:: 20.0 :attr:`can_manage_topics` is considered as well when comparing objects of @@ -47,6 +47,9 @@ class ChatPermissions(TelegramObject): :attr:`can_send_videos`, :attr:`can_send_video_notes` and :attr:`can_send_voice_notes` are considered as well when comparing objects of this type in terms of equality. * Removed deprecated argument and attribute ``can_send_media_messages``. + .. versionchanged:: NEXT.VERSION + :attr:`can_edit_tag` is considered as well when comparing objects of + this type in terms of equality. Note: @@ -93,6 +96,10 @@ class ChatPermissions(TelegramObject): notes. .. versionadded:: 20.1 + can_edit_tag (:obj:`bool`, optional): :obj:`True`, if the user is allowed to edit their own + tag. + + .. versionadded:: NEXT.VERSION Attributes: can_send_messages (:obj:`bool`): Optional. :obj:`True`, if the user is allowed to send text @@ -134,12 +141,17 @@ class ChatPermissions(TelegramObject): notes. .. versionadded:: 20.1 + can_edit_tag (:obj:`bool`): Optional. :obj:`True`, if the user is allowed to edit their own + tag. + + .. versionadded:: NEXT.VERSION """ __slots__ = ( "can_add_web_page_previews", "can_change_info", + "can_edit_tag", "can_invite_users", "can_manage_topics", "can_pin_messages", @@ -170,6 +182,7 @@ class ChatPermissions(TelegramObject): can_send_videos: bool | None = None, can_send_video_notes: bool | None = None, can_send_voice_notes: bool | None = None, + can_edit_tag: bool | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -189,6 +202,7 @@ class ChatPermissions(TelegramObject): self.can_send_videos: bool | None = can_send_videos self.can_send_video_notes: bool | None = can_send_video_notes self.can_send_voice_notes: bool | None = can_send_voice_notes + self.can_edit_tag: bool | None = can_edit_tag self._id_attrs = ( self.can_send_messages, @@ -205,6 +219,7 @@ class ChatPermissions(TelegramObject): self.can_send_videos, self.can_send_video_notes, self.can_send_voice_notes, + self.can_edit_tag, ) self._freeze() @@ -219,7 +234,7 @@ class ChatPermissions(TelegramObject): .. versionadded:: 20.0 """ - return cls(*(14 * (True,))) + return cls(*(True,) * len(cls.__slots__)) @classmethod def no_permissions(cls) -> "ChatPermissions": @@ -229,7 +244,7 @@ class ChatPermissions(TelegramObject): .. versionadded:: 20.0 """ - return cls(*(14 * (False,))) + return cls(*(False,) * len(cls.__slots__)) @classmethod def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatPermissions": diff --git a/src/telegram/_message.py b/src/telegram/_message.py index 10ae4d9c9..4fce7f2a7 100644 --- a/src/telegram/_message.py +++ b/src/telegram/_message.py @@ -74,7 +74,7 @@ from telegram._telegramobject import TelegramObject from telegram._uniquegift import UniqueGiftInfo from telegram._user import User from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg -from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp, to_timestamp from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue from telegram._utils.entities import parse_message_entities, parse_message_entity from telegram._utils.strings import TextEncoding @@ -330,8 +330,8 @@ class Message(MaybeInaccessibleMessage): or as a scheduled message. .. versionadded:: 21.1 - media_group_id (:obj:`str`, optional): The unique identifier of a media message group this - message belongs to. + media_group_id (:obj:`str`, optional): The unique identifier inside this chat of a media + message group this message belongs to. text (:obj:`str`, optional): For text messages, the actual UTF-8 text of the message, 0-:tg-const:`telegram.constants.MessageLimit.MAX_TEXT_LENGTH` characters. entities (Sequence[:class:`telegram.MessageEntity`], optional): For text messages, special @@ -684,6 +684,10 @@ class Message(MaybeInaccessibleMessage): chat_owner_changed (:class:`telegram.ChatOwnerChanged`, optional): Service message: chat owner has changed. + .. versionadded:: NEXT.VERSION + sender_tag (:obj:`str`, optional): Tag or custom title of the sender of the message; for + supergroups only + .. versionadded:: NEXT.VERSION Attributes: @@ -726,8 +730,8 @@ class Message(MaybeInaccessibleMessage): or as a scheduled message. .. versionadded:: 21.1 - media_group_id (:obj:`str`): Optional. The unique identifier of a media message group this - message belongs to. + media_group_id (:obj:`str`): Optional. The unique identifier inside this chat of a media + message group this message belongs to. text (:obj:`str`): Optional. For text messages, the actual UTF-8 text of the message, 0-:tg-const:`telegram.constants.MessageLimit.MAX_TEXT_LENGTH` characters. entities (tuple[:class:`telegram.MessageEntity`]): Optional. For text messages, special @@ -1096,6 +1100,10 @@ class Message(MaybeInaccessibleMessage): chat_owner_changed (:class:`telegram.ChatOwnerChanged`): Optional. Service message: chat owner has changed. + .. versionadded:: NEXT.VERSION + sender_tag (:obj:`str`): Optional. Tag or custom title of the sender of the message; for + supergroups only + .. versionadded:: NEXT.VERSION .. |custom_emoji_no_md1_support| replace:: Since custom emoji entities are not supported by @@ -1193,6 +1201,7 @@ class Message(MaybeInaccessibleMessage): "sender_boost_count", "sender_business_bot", "sender_chat", + "sender_tag", "show_caption_above_media", "sticker", "story", @@ -1327,6 +1336,7 @@ class Message(MaybeInaccessibleMessage): gift_upgrade_sent: GiftInfo | None = None, chat_owner_changed: ChatOwnerChanged | None = None, chat_owner_left: ChatOwnerLeft | None = None, + sender_tag: str | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -1456,6 +1466,7 @@ class Message(MaybeInaccessibleMessage): self.gift_upgrade_sent: GiftInfo | None = gift_upgrade_sent self.chat_owner_changed: ChatOwnerChanged | None = chat_owner_changed self.chat_owner_left: ChatOwnerLeft | None = chat_owner_left + self.sender_tag: str | None = sender_tag self._effective_attachment = DEFAULT_NONE @@ -5304,6 +5315,17 @@ class Message(MaybeInaccessibleMessage): insert = f'{escaped_text}' elif entity.type == MessageEntity.CUSTOM_EMOJI: insert = f'{escaped_text}' + elif entity.type == MessageEntity.DATE_TIME: + if entity.date_time_format: + insert = ( + f'{escaped_text}' + ) + else: + insert = ( + f'' + f"{escaped_text}" + ) else: insert = escaped_text @@ -5443,6 +5465,7 @@ class Message(MaybeInaccessibleMessage): MessageEntity.SPOILER, MessageEntity.STRIKETHROUGH, MessageEntity.UNDERLINE, + MessageEntity.DATE_TIME, ): if any(entity.type == entity_type for entity in entities): name = entity_type.name.title().replace("_", " ") # type:ignore[attr-defined] @@ -5535,6 +5558,14 @@ class Message(MaybeInaccessibleMessage): entity_type=MessageEntity.CUSTOM_EMOJI, ) insert = f"![{escaped_text}](tg://emoji?id={custom_emoji_id})" + elif entity.type == MessageEntity.DATE_TIME: + if entity.date_time_format: + insert = ( + f"![{escaped_text}](tg://time?unix={to_timestamp(entity.unix_time)}" + f"&format={entity.date_time_format})" + ) + else: + insert = f"![{escaped_text}](tg://time?unix={to_timestamp(entity.unix_time)})" else: insert = escaped_text diff --git a/src/telegram/_messageentity.py b/src/telegram/_messageentity.py index 37fb597a4..6833fbc26 100644 --- a/src/telegram/_messageentity.py +++ b/src/telegram/_messageentity.py @@ -19,6 +19,7 @@ """This module contains an object that represents a Telegram MessageEntity.""" import copy +import datetime as dtm import itertools from collections.abc import Sequence from typing import TYPE_CHECKING, Final @@ -28,6 +29,7 @@ from telegram._telegramobject import TelegramObject from telegram._user import User from telegram._utils import enum from telegram._utils.argumentparsing import de_json_optional +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.strings import TextEncoding from telegram._utils.types import JSONDict @@ -55,13 +57,17 @@ class MessageEntity(TelegramObject): (underlined text), :attr:`STRIKETHROUGH`, :attr:`SPOILER` (spoiler message), :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). + (for users without usernames), :attr:`CUSTOM_EMOJI` (for inline custom emoji stickers) + or :attr:`DATE_TIME`(for formatted date and time). .. versionadded:: 20.0 Added inline custom emoji .. versionadded:: 20.8 Added block quotation + + .. versionadded:: NEXT.VERSION + Added date_time 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 @@ -75,6 +81,17 @@ class MessageEntity(TelegramObject): information about the sticker. .. versionadded:: 20.0 + date_time_format (:obj:`str`, optional): For :attr`DATE_TIME` only, the string that defines + the formatting of the date and time. See `date-time entity formatting + `_ for more details and + :tg-const:`telegram.constants.MessageEntityDateTimeFormats` for all possible formats. + + .. versionadded:: NEXT.VERSION + unix_time (:class:`datetime.datetime`, optional): For :attr:`DATE_TIME` only, the time + associated with the entity. + |datetime_localization| + + .. versionadded:: NEXT.VERSION Attributes: type (:obj:`str`): Type of the entity. Can be :attr:`MENTION` (``@username``), :attr:`HASHTAG` (``#hashtag`` or ``#hashtag@chatusername``), :attr:`CASHTAG` (``$USD`` @@ -105,10 +122,31 @@ class MessageEntity(TelegramObject): information about the sticker. .. versionadded:: 20.0 + date_time_format (:obj:`str`): Optional. For :attr`DATE_TIME` only, the string that defines + the formatting of the date and time. See `date-time entity formatting + `_ for more details and + :tg-const:`telegram.constants.MessageEntityDateTimeFormats` for all possible formats. + + .. versionadded:: NEXT.VERSION + unix_time (:class:`datetime.datetime`): Optional. For :attr:`DATE_TIME` only, the time + associated with the entity. + |datetime_localization| + + .. versionadded:: NEXT.VERSION """ - __slots__ = ("custom_emoji_id", "language", "length", "offset", "type", "url", "user") + __slots__ = ( + "custom_emoji_id", + "date_time_format", + "language", + "length", + "offset", + "type", + "unix_time", + "url", + "user", + ) def __init__( self, @@ -119,6 +157,8 @@ class MessageEntity(TelegramObject): user: User | None = None, language: str | None = None, custom_emoji_id: str | None = None, + date_time_format: str | None = None, + unix_time: dtm.datetime | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -132,6 +172,8 @@ class MessageEntity(TelegramObject): self.user: User | None = user self.language: str | None = language self.custom_emoji_id: str | None = custom_emoji_id + self.date_time_format: str | None = date_time_format + self.unix_time: dtm.datetime | None = unix_time self._id_attrs = (self.type, self.offset, self.length) @@ -144,6 +186,10 @@ class MessageEntity(TelegramObject): data["user"] = de_json_optional(data.get("user"), User, bot) + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["unix_time"] = from_timestamp(data.get("unix_time"), tzinfo=loc_tzinfo) + return super().de_json(data=data, bot=bot) @staticmethod @@ -376,6 +422,11 @@ class MessageEntity(TelegramObject): .. versionadded:: 20.0 """ + DATE_TIME: Final[str] = constants.MessageEntityType.DATE_TIME + """:const:`telegram.constants.MessageEntityType.DATE_TIME` + + .. versionadded:: NEXT.VERSION + """ EMAIL: Final[str] = constants.MessageEntityType.EMAIL """:const:`telegram.constants.MessageEntityType.EMAIL`""" EXPANDABLE_BLOCKQUOTE: Final[str] = constants.MessageEntityType.EXPANDABLE_BLOCKQUOTE diff --git a/src/telegram/_user.py b/src/telegram/_user.py index f60584927..dd3609cd2 100644 --- a/src/telegram/_user.py +++ b/src/telegram/_user.py @@ -2677,3 +2677,38 @@ class User(TelegramObject): pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) + + async def set_chat_member_tag( + self, + chat_id: int | str, + tag: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Shortcut for:: + + await bot.set_chat_member_tag(user_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.set_chat_member_tag`. + + .. versionadded:: NEXT.VERSION + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().set_chat_member_tag( + user_id=self.id, + chat_id=chat_id, + tag=tag, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) diff --git a/src/telegram/constants.py b/src/telegram/constants.py index 26bc6582c..d861dd745 100644 --- a/src/telegram/constants.py +++ b/src/telegram/constants.py @@ -91,6 +91,7 @@ __all__ = [ "MediaGroupLimit", "MenuButtonType", "MessageAttachmentType", + "MessageEntityDateTimeFormats", "MessageEntityType", "MessageLimit", "MessageOriginType", @@ -121,6 +122,7 @@ __all__ = [ "SuggestedPost", "SuggestedPostInfoState", "SuggestedPostRefunded", + "TagLimit", "TransactionPartnerType", "TransactionPartnerUser", "UniqueGiftInfoOrigin", @@ -179,7 +181,7 @@ class _AccentColor(NamedTuple): #: :data:`telegram.__bot_api_version_info__`. #: #: .. versionadded:: 20.0 -BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=9, minor=4) +BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=9, minor=5) #: :obj:`str`: Telegram Bot API #: version supported by this version of `python-telegram-bot`. Also available as #: :data:`telegram.__bot_api_version__`. @@ -2017,6 +2019,11 @@ class MessageEntityType(StringEnum): .. versionadded:: 20.0 """ + DATE_TIME = "date_time" + """:obj:`str`: Message entities representing formatted date and time. + + .. versionadded:: NEXT.VERSION + """ EMAIL = "email" """:obj:`str`: Message entities representing a email.""" EXPANDABLE_BLOCKQUOTE = "expandable_blockquote" @@ -2048,6 +2055,63 @@ class MessageEntityType(StringEnum): """:obj:`str`: Message entities representing a url.""" +class MessageEntityDateTimeFormats(StringEnum): + """This enum contains all possible formats for :attr:`telegram.MessageEntity.date_time_format`. + Please read `date-time entity formatting + `_ for more details. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: NEXT.VERSION + """ + + __slots__ = () + + RELATIVE = "r" + """:obj:`str`: Displays the time relative to the current time.""" + LOCALIZED_WEEKDAY = "w" + """:obj:`str`: Displays the day of the week in the user's localized language.""" + SHORT_DATE = "d" + """:obj:`str`: Displays the date in short form (e.g., "17.03.22").""" + LONG_DATE = "D" + """:obj:`str`: Displays the date in long form (e.g., "March 17, 2022").""" + SHORT_TIME = "t" + """:obj:`str`: Displays the time in short form (e.g., "22:45").""" + LONG_TIME = "T" + """:obj:`str`: Displays the time in long form (e.g., "22:45:00").""" + LOCALIZED_WEEKDAY_SHORT_DATE = "wd" + """:obj:`str`: Displays the day of the week in the user's localized language and the date in + short form.""" + LOCALIZED_WEEKDAY_LONG_DATE = "wD" + """:obj:`str`: Displays the day of the week in the user's localized language and the date in + long form.""" + LOCALIZED_WEEKDAY_SHORT_TIME = "wt" + """:obj:`str`: Displays the day of the week in the user's localized language and the time in + short form.""" + LOCALIZED_WEEKDAY_LONG_TIME = "wT" + """:obj:`str`: Displays the day of the week in the user's localized language and the time in + long form.""" + LOCALIZED_WEEKDAY_SHORT_DATE_SHORT_TIME = "wdt" + """:obj:`str`: Displays the day of the week in the user's localized language, the date in + short form and the time in short form.""" + LOCALIZED_WEEKDAY_SHORT_DATE_LONG_TIME = "wdT" + """:obj:`str`: Displays the day of the week in the user's localized language, the date in + short form and the time in long form.""" + LOCALIZED_WEEKDAY_LONG_DATE_SHORT_TIME = "wDt" + """:obj:`str`: Displays the day of the week in the user's localized language, the date in + long form and the time in short form.""" + LOCALIZED_WEEKDAY_LONG_DATE_LONG_TIME = "wDT" + """:obj:`str`: Displays the day of the week in the user's localized language, the date in + long form and the time in long form.""" + SHORT_DATE_SHORT_TIME = "dt" + """:obj:`str`: Displays the date in short form and the time in short form.""" + SHORT_DATE_LONG_TIME = "dT" + """:obj:`str`: Displays the date in short form and the time in long form.""" + LONG_DATE_SHORT_TIME = "Dt" + """:obj:`str`: Displays the date in long form and the time in short form.""" + LONG_DATE_LONG_TIME = "DT" + """:obj:`str`: Displays the date in long form and the time in long form.""" + + class MessageLimit(IntEnum): """This enum contains limitations for :class:`telegram.Message`/ :class:`telegram.InputTextMessageContent`/ @@ -3964,3 +4028,18 @@ class VerifyLimit(IntEnum): :paramref:`~telegram.Bot.verify_chat.custom_description` or :paramref:`~telegram.Bot.verify_user.custom_description` parameter. """ + + +class TagLimit(IntEnum): + """This enum contains limitations for :meth:`~telegram.Bot.set_chat_member_tag`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: NEXT.VERSION + """ + + __slots__ = () + + MAX_TAG_LENGTH = 16 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the + :paramref:`~telegram.Bot.set_chat_member_tag.tag` parameter. + """ diff --git a/src/telegram/ext/_extbot.py b/src/telegram/ext/_extbot.py index 1b1aaff94..c09b1a5f6 100644 --- a/src/telegram/ext/_extbot.py +++ b/src/telegram/ext/_extbot.py @@ -2367,6 +2367,7 @@ class ExtBot(Bot, Generic[RLARGS]): can_edit_stories: bool | None = None, can_delete_stories: bool | None = None, can_manage_direct_messages: bool | None = None, + can_manage_tags: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2394,6 +2395,7 @@ class ExtBot(Bot, Generic[RLARGS]): can_edit_stories=can_edit_stories, can_delete_stories=can_delete_stories, can_manage_direct_messages=can_manage_direct_messages, + can_manage_tags=can_manage_tags, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -5485,6 +5487,30 @@ class ExtBot(Bot, Generic[RLARGS]): api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def set_chat_member_tag( + self, + chat_id: int | str, + user_id: int, + tag: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().set_chat_member_tag( + chat_id=chat_id, + user_id=user_id, + tag=tag, + 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 @@ -5650,3 +5676,4 @@ class ExtBot(Bot, Generic[RLARGS]): setMyProfilePhoto = set_my_profile_photo removeMyProfilePhoto = remove_my_profile_photo getUserProfileAudios = get_user_profile_audios + setChatMemberTag = set_chat_member_tag diff --git a/tests/test_bot.py b/tests/test_bot.py index ade008a07..1585c581a 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -2835,6 +2835,18 @@ class TestBotWithoutRequest: limit="limit", ) + # TODO if we have a group member id in every group we could test this + async def test_set_chat_member_tag(self, offline_bot, monkeypatch): + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("chat_id") == 1234 + assert request_data.parameters.get("user_id") == 5678 + assert request_data.parameters.get("tag") == "This is a tag" + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + await offline_bot.set_chat_member_tag(1234, 5678, "This is a tag") + class TestBotWithRequest: """ @@ -3808,6 +3820,7 @@ class TestBotWithRequest: can_edit_stories=True, can_delete_stories=True, can_manage_direct_messages=True, + can_manage_tags=True, ) # Test that we pass the correct params to TG @@ -3832,6 +3845,7 @@ class TestBotWithRequest: and data.get("can_edit_stories") == 14 and data.get("can_delete_stories") == 15 and data.get("can_manage_direct_messages") == 16 + and data.get("can_manage_tags") == 17 ) monkeypatch.setattr(bot, "_post", make_assertion) @@ -3854,6 +3868,7 @@ class TestBotWithRequest: can_edit_stories=14, can_delete_stories=15, can_manage_direct_messages=16, + can_manage_tags=17, ) async def test_export_chat_invite_link(self, bot, channel_id): diff --git a/tests/test_chat.py b/tests/test_chat.py index 624711da2..8d1d2db23 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -1548,6 +1548,25 @@ class TestChatWithoutRequest(ChatTestBase): monkeypatch.setattr(chat.get_bot(), "get_chat_gifts", make_assertion) assert await chat.get_gifts() + async def test_instance_method_set_chat_member_tag(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["user_id"] == "user_id" + and kwargs["tag"] == "tag" + ) + + assert check_shortcut_signature( + Chat.set_chat_member_tag, Bot.set_chat_member_tag, ["chat_id"], [] + ) + assert await check_shortcut_call( + chat.set_chat_member_tag, chat.get_bot(), "set_chat_member_tag" + ) + assert await check_defaults_handling(chat.set_chat_member_tag, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "set_chat_member_tag", make_assertion) + assert await chat.set_chat_member_tag(user_id="user_id", tag="tag") + 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_chatadministratorrights.py b/tests/test_chatadministratorrights.py index f7a9ba092..c66cd98e5 100644 --- a/tests/test_chatadministratorrights.py +++ b/tests/test_chatadministratorrights.py @@ -41,6 +41,7 @@ def chat_admin_rights(): can_edit_stories=True, can_delete_stories=True, can_manage_direct_messages=True, + can_manage_tags=True, ) @@ -69,6 +70,7 @@ class TestChatAdministratorRightsWithoutRequest: "can_edit_stories": True, "can_delete_stories": True, "can_manage_direct_messages": True, + "can_manage_tags": True, } chat_administrator_rights_de = ChatAdministratorRights.de_json(json_dict, offline_bot) assert chat_administrator_rights_de.api_kwargs == {} @@ -96,6 +98,7 @@ class TestChatAdministratorRightsWithoutRequest: assert admin_rights_dict["can_edit_stories"] == car.can_edit_stories assert admin_rights_dict["can_delete_stories"] == car.can_delete_stories assert admin_rights_dict["can_manage_direct_messages"] == car.can_manage_direct_messages + assert admin_rights_dict["can_manage_tags"] == car.can_manage_tags def test_equality(self): a = ChatAdministratorRights( @@ -147,6 +150,7 @@ class TestChatAdministratorRightsWithoutRequest: True, True, True, + True, ) t = ChatAdministratorRights.all_rights() # if the dirs are the same, the attributes will all be there @@ -173,6 +177,7 @@ class TestChatAdministratorRightsWithoutRequest: False, False, False, + False, ) t = ChatAdministratorRights.no_rights() # if the dirs are the same, the attributes will all be there diff --git a/tests/test_chatmember.py b/tests/test_chatmember.py index 2ef55b067..7bc4a2979 100644 --- a/tests/test_chatmember.py +++ b/tests/test_chatmember.py @@ -76,6 +76,9 @@ class ChatMemberTestBase: can_send_messages = True is_member = True can_manage_direct_messages = True + can_manage_tags = True + can_edit_tag = True + tag = "test_tag" class TestChatMemberWithoutRequest(ChatMemberTestBase): @@ -172,6 +175,7 @@ def chat_member_administrator(): TestChatMemberAdministratorWithoutRequest.custom_title, TestChatMemberAdministratorWithoutRequest.is_anonymous, TestChatMemberAdministratorWithoutRequest.can_manage_direct_messages, + TestChatMemberAdministratorWithoutRequest.can_manage_tags, ) @@ -205,6 +209,7 @@ class TestChatMemberAdministratorWithoutRequest(ChatMemberTestBase): "custom_title": self.custom_title, "is_anonymous": self.is_anonymous, "can_manage_direct_messages": self.can_manage_direct_messages, + "can_manage_tags": self.can_manage_tags, } chat_member = ChatMemberAdministrator.de_json(data, offline_bot) @@ -230,6 +235,7 @@ class TestChatMemberAdministratorWithoutRequest(ChatMemberTestBase): assert chat_member.custom_title == self.custom_title assert chat_member.is_anonymous == self.is_anonymous assert chat_member.can_manage_direct_messages == self.can_manage_direct_messages + assert chat_member.can_manage_tags == self.can_manage_tags def test_to_dict(self, chat_member_administrator): assert chat_member_administrator.to_dict() == { @@ -253,6 +259,7 @@ class TestChatMemberAdministratorWithoutRequest(ChatMemberTestBase): "custom_title": chat_member_administrator.custom_title, "is_anonymous": chat_member_administrator.is_anonymous, "can_manage_direct_messages": chat_member_administrator.can_manage_direct_messages, + "can_manage_tags": chat_member_administrator.can_manage_tags, } def test_equality(self, chat_member_administrator): @@ -272,6 +279,7 @@ class TestChatMemberAdministratorWithoutRequest(ChatMemberTestBase): True, True, True, + True, ) c = ChatMemberAdministrator( User(1, "test_user", is_bot=False), @@ -288,6 +296,7 @@ class TestChatMemberAdministratorWithoutRequest(ChatMemberTestBase): False, False, False, + False, ) d = Dice(5, "test") @@ -566,6 +575,8 @@ def chat_member_restricted(): can_send_voice_notes=TestChatMemberRestrictedWithoutRequest.can_send_voice_notes, is_member=TestChatMemberRestrictedWithoutRequest.is_member, until_date=TestChatMemberRestrictedWithoutRequest.until_date, + can_edit_tag=TestChatMemberRestrictedWithoutRequest.can_edit_tag, + tag=TestChatMemberRestrictedWithoutRequest.tag, ) @@ -597,6 +608,8 @@ class TestChatMemberRestrictedWithoutRequest(ChatMemberTestBase): "can_send_voice_notes": self.can_send_voice_notes, "is_member": self.is_member, "until_date": to_timestamp(self.until_date), + "can_edit_tag": self.can_edit_tag, + "tag": self.tag, # legacy argument "can_send_media_messages": False, } @@ -622,6 +635,8 @@ class TestChatMemberRestrictedWithoutRequest(ChatMemberTestBase): assert chat_member.can_send_voice_notes == self.can_send_voice_notes assert chat_member.is_member == self.is_member assert chat_member.until_date == self.until_date + assert chat_member.can_edit_tag == self.can_edit_tag + assert chat_member.tag == self.tag def test_de_json_localization(self, tz_bot, offline_bot, raw_bot, chat_member_restricted): json_dict = chat_member_restricted.to_dict() @@ -660,6 +675,8 @@ class TestChatMemberRestrictedWithoutRequest(ChatMemberTestBase): "can_send_voice_notes": chat_member_restricted.can_send_voice_notes, "is_member": chat_member_restricted.is_member, "until_date": to_timestamp(chat_member_restricted.until_date), + "can_edit_tag": chat_member_restricted.can_edit_tag, + "tag": chat_member_restricted.tag, } def test_equality(self, chat_member_restricted): @@ -683,6 +700,8 @@ class TestChatMemberRestrictedWithoutRequest(ChatMemberTestBase): False, False, False, + False, + "tag", ) d = Dice(5, "test") diff --git a/tests/test_chatpermissions.py b/tests/test_chatpermissions.py index 54de0bc00..0baee3f89 100644 --- a/tests/test_chatpermissions.py +++ b/tests/test_chatpermissions.py @@ -40,6 +40,7 @@ def chat_permissions(): can_send_videos=True, can_send_video_notes=True, can_send_voice_notes=True, + can_edit_tag=True, ) @@ -58,6 +59,7 @@ class ChatPermissionsTestBase: can_send_videos = True can_send_video_notes = False can_send_voice_notes = None + can_edit_tag = None class TestChatPermissionsWithoutRequest(ChatPermissionsTestBase): @@ -83,6 +85,7 @@ class TestChatPermissionsWithoutRequest(ChatPermissionsTestBase): "can_send_videos": self.can_send_videos, "can_send_video_notes": self.can_send_video_notes, "can_send_voice_notes": self.can_send_voice_notes, + "can_edit_tag": self.can_edit_tag, } permissions = ChatPermissions.de_json(json_dict, offline_bot) assert permissions.api_kwargs == {"can_send_media_messages": "can_send_media_messages"} @@ -101,6 +104,7 @@ class TestChatPermissionsWithoutRequest(ChatPermissionsTestBase): assert permissions.can_send_videos == self.can_send_videos assert permissions.can_send_video_notes == self.can_send_video_notes assert permissions.can_send_voice_notes == self.can_send_voice_notes + assert permissions.can_edit_tag == self.can_edit_tag def test_to_dict(self, chat_permissions): permissions_dict = chat_permissions.to_dict() @@ -125,6 +129,7 @@ class TestChatPermissionsWithoutRequest(ChatPermissionsTestBase): assert permissions_dict["can_send_videos"] == chat_permissions.can_send_videos assert permissions_dict["can_send_video_notes"] == chat_permissions.can_send_video_notes assert permissions_dict["can_send_voice_notes"] == chat_permissions.can_send_voice_notes + assert permissions_dict["can_edit_tag"] == chat_permissions.can_edit_tag def test_equality(self): a = ChatPermissions( @@ -153,6 +158,7 @@ class TestChatPermissionsWithoutRequest(ChatPermissionsTestBase): can_send_videos=True, can_send_video_notes=True, can_send_voice_notes=True, + can_edit_tag=True, ) f = ChatPermissions( can_send_messages=True, @@ -164,6 +170,7 @@ class TestChatPermissionsWithoutRequest(ChatPermissionsTestBase): can_send_videos=True, can_send_video_notes=True, can_send_voice_notes=True, + can_edit_tag=True, ) assert a == b diff --git a/tests/test_constants.py b/tests/test_constants.py index 052680335..40fd2d418 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -198,6 +198,7 @@ class TestConstantsWithoutRequest: "reply_markup", "reply_to_message", "sender_chat", + "sender_tag", "is_accessible", "quote", "external_reply", diff --git a/tests/test_message.py b/tests/test_message.py index 3193516bb..2873f1741 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -19,6 +19,7 @@ import datetime as dtm from copy import copy, deepcopy +from zoneinfo import ZoneInfo import pytest @@ -434,6 +435,7 @@ def message(bot): }, {"chat_owner_changed": ChatOwnerChanged(new_owner=User(4, "Snow", False))}, {"chat_owner_left": ChatOwnerLeft(new_owner=User(5, "Crash", False))}, + {"sender_tag": "This is a tag"}, ], ids=[ "reply", @@ -527,6 +529,7 @@ def message(bot): "gift_upgrade_sent", "chat_owner_changed", "chat_owner_left", + "sender_tag", ], ) def message_params(bot, request): @@ -585,11 +588,20 @@ class MessageTestBase: {"length": 34, "offset": 154, "type": "blockquote"}, {"length": 6, "offset": 181, "type": "bold"}, {"length": 33, "offset": 190, "type": "expandable_blockquote"}, + {"length": 4, "offset": 224, "type": "date_time", "unix_time": dtm.datetime(2000, 7, 28)}, + { + "length": 14, + "offset": 229, + "type": "date_time", + "unix_time": dtm.datetime(2000, 7, 28, tzinfo=ZoneInfo("Europe/Berlin")), + "date_time_format": "r", + }, ] test_text_v2 = ( r"Test for trgh nested in italic. Python pre. Spoiled. " - "👍.\nMultiline\nblock quote\nwith nested.\n\nMultiline\nexpandable\nblock quote." + "👍.\nMultiline\nblock quote\nwith nested.\n\nMultiline\nexpandable\nblock quote.\ntime" + "\ntime_formatted\n" ) test_message = Message( message_id=1, @@ -957,7 +969,9 @@ class TestMessageWithoutRequest(MessageTestBase): 'Spoiled. ' '👍.\n' "
Multiline\nblock quote\nwith nested.
\n\n" - "
Multiline\nexpandable\nblock quote.
" + "
Multiline\nexpandable\nblock quote.
\n" + 'time\n' + 'time_formatted\n' ) text_html = self.test_message_v2.text_html assert text_html == test_html_string @@ -979,7 +993,9 @@ class TestMessageWithoutRequest(MessageTestBase): 'Spoiled. ' '👍.\n' "
Multiline\nblock quote\nwith nested.
\n\n" - "
Multiline\nexpandable\nblock quote.
" + "
Multiline\nexpandable\nblock quote.
\n" + 'time\n' + 'time_formatted\n' ) text_html = self.test_message_v2.text_html_urled assert text_html == test_html_string @@ -1007,6 +1023,8 @@ class TestMessageWithoutRequest(MessageTestBase): "\n\n>Multiline\n" ">expandable\n" r">block quote\.||" + "\n![time](tg://time?unix=964742400)\n" + "![time\\_formatted](tg://time?unix=964735200&format=r)\n" ) text_markdown = self.test_message_v2.text_markdown_v2 assert text_markdown == test_md_string @@ -1066,6 +1084,8 @@ class TestMessageWithoutRequest(MessageTestBase): "\n\n>Multiline\n" ">expandable\n" r">block quote\.||" + "\n![time](tg://time?unix=964742400)\n" + "![time\\_formatted](tg://time?unix=964735200&format=r)\n" ) text_markdown = self.test_message_v2.text_markdown_v2_urled assert text_markdown == test_md_string @@ -1183,7 +1203,9 @@ class TestMessageWithoutRequest(MessageTestBase): 'Spoiled. ' '👍.\n' "
Multiline\nblock quote\nwith nested.
\n\n" - "
Multiline\nexpandable\nblock quote.
" + "
Multiline\nexpandable\nblock quote.
\n" + 'time\n' + 'time_formatted\n' ) caption_html = self.test_message_v2.caption_html assert caption_html == test_html_string @@ -1205,7 +1227,9 @@ class TestMessageWithoutRequest(MessageTestBase): 'Spoiled. ' '👍.\n' "
Multiline\nblock quote\nwith nested.
\n\n" - "
Multiline\nexpandable\nblock quote.
" + "
Multiline\nexpandable\nblock quote.
\n" + 'time\n' + 'time_formatted\n' ) caption_html = self.test_message_v2.caption_html_urled assert caption_html == test_html_string @@ -1233,6 +1257,8 @@ class TestMessageWithoutRequest(MessageTestBase): "\n\n>Multiline\n" ">expandable\n" r">block quote\.||" + "\n![time](tg://time?unix=964742400)\n" + "![time\\_formatted](tg://time?unix=964735200&format=r)\n" ) caption_markdown = self.test_message_v2.caption_markdown_v2 assert caption_markdown == test_md_string @@ -1267,6 +1293,8 @@ class TestMessageWithoutRequest(MessageTestBase): "\n\n>Multiline\n" ">expandable\n" r">block quote\.||" + "\n![time](tg://time?unix=964742400)\n" + "![time\\_formatted](tg://time?unix=964735200&format=r)\n" ) caption_markdown = self.test_message_v2.caption_markdown_v2_urled assert caption_markdown == test_md_string @@ -1746,6 +1774,8 @@ class TestMessageWithoutRequest(MessageTestBase): "\n\n>Multiline\n" ">expandable\n" r">block quote\.||" + "\n![time](tg://time?unix=964742400)\n" + "![time\\_formatted](tg://time?unix=964735200&format=r)\n" ) async def make_assertion(*_, **kwargs): @@ -1803,7 +1833,9 @@ class TestMessageWithoutRequest(MessageTestBase): 'Spoiled. ' '👍.\n' "
Multiline\nblock quote\nwith nested.
\n\n" - "
Multiline\nexpandable\nblock quote.
" + "
Multiline\nexpandable\nblock quote.
\n" + 'time\n' + 'time_formatted\n' ) async def make_assertion(*_, **kwargs): diff --git a/tests/test_messageentity.py b/tests/test_messageentity.py index 633507900..7341ce56c 100644 --- a/tests/test_messageentity.py +++ b/tests/test_messageentity.py @@ -16,11 +16,13 @@ # # 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 random import pytest from telegram import MessageEntity, User +from telegram._utils.datetime import UTC, to_timestamp from telegram.constants import MessageEntityType from tests.auxil.slots import mro_slots @@ -37,7 +39,25 @@ def message_entity(request): language = None if type_ == MessageEntity.PRE: language = "python" - return MessageEntity(type_, 1, 3, url=url, user=user, language=language) + custom_emoji_id = None + if type_ == MessageEntity.CUSTOM_EMOJI: + custom_emoji_id = "emoji_id" + date_time_format = None + unix_time = None + if type_ == MessageEntity.DATE_TIME: + date_time_format = "wDT" + unix_time = dtm.datetime.now(tz=UTC) + return MessageEntity( + type_, + 1, + 3, + url=url, + user=user, + language=language, + custom_emoji_id=custom_emoji_id, + date_time_format=date_time_format, + unix_time=unix_time, + ) class MessageEntityTestBase: @@ -76,6 +96,11 @@ class TestMessageEntityWithoutRequest(MessageEntityTestBase): assert entity_dict["user"] == message_entity.user.to_dict() if message_entity.language: assert entity_dict["language"] == message_entity.language + if message_entity.custom_emoji_id: + assert entity_dict["custom_emoji_id"] == message_entity.custom_emoji_id + if message_entity.date_time_format: + assert entity_dict["date_time_format"] == message_entity.date_time_format + assert entity_dict["unix_time"] == to_timestamp(message_entity.unix_time) def test_enum_init(self): entity = MessageEntity(type="foo", offset=0, length=1) diff --git a/tests/test_user.py b/tests/test_user.py index 520683e10..ba237ea47 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -887,3 +887,22 @@ class TestUserWithoutRequest(UserTestBase): monkeypatch.setattr(user.get_bot(), "get_user_gifts", make_assertion) assert await user.get_gifts() + + async def test_instance_method_set_chat_member_tag(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return ( + kwargs["user_id"] == user.id + and kwargs["chat_id"] == "chat_id" + and kwargs["tag"] == "tag" + ) + + assert check_shortcut_signature( + user.set_chat_member_tag, Bot.set_chat_member_tag, ["user_id"], [] + ) + assert await check_shortcut_call( + user.set_chat_member_tag, user.get_bot(), "set_chat_member_tag" + ) + assert await check_defaults_handling(user.set_chat_member_tag, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "set_chat_member_tag", make_assertion) + assert await user.set_chat_member_tag(chat_id="chat_id", tag="tag")