From 939643e943bd274376253eff867dceaa430c75dd Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Thu, 16 Oct 2025 01:23:48 +0300 Subject: [PATCH 01/11] Add field `User.has_topics_enabled` --- src/telegram/_user.py | 11 +++++++++++ tests/test_user.py | 5 +++++ 2 files changed, 16 insertions(+) diff --git a/src/telegram/_user.py b/src/telegram/_user.py index 3d49931ca1d..e090b2b6b58 100644 --- a/src/telegram/_user.py +++ b/src/telegram/_user.py @@ -113,6 +113,10 @@ class User(TelegramObject): Returned only in :meth:`telegram.Bot.get_me`. .. versionadded:: 21.5 + has_topics_enabled (:obj:`bool`, optional): :obj:`True`, if the bot has forum topic mode + enabled in private chats. Returned only in :meth:`telegram.Bot.get_me`. + + .. versionadded:: NEXT.VERSION Attributes: id (:obj:`int`): Unique identifier for this user or bot. @@ -144,6 +148,10 @@ class User(TelegramObject): Returned only in :meth:`telegram.Bot.get_me`. .. versionadded:: 21.5 + has_topics_enabled (:obj:`bool`): Optional. :obj:`True`, if the bot has forum topic mode + enabled in private chats. Returned only in :meth:`telegram.Bot.get_me`. + + .. versionadded:: NEXT.VERSION .. |user_chat_id_note| replace:: This shortcuts build on the assumption that :attr:`User.id` coincides with the :attr:`Chat.id` of the private chat with the user. This has been the @@ -157,6 +165,7 @@ class User(TelegramObject): "can_read_all_group_messages", "first_name", "has_main_web_app", + "has_topics_enabled", "id", "is_bot", "is_premium", @@ -181,6 +190,7 @@ def __init__( added_to_attachment_menu: Optional[bool] = None, can_connect_to_business: Optional[bool] = None, has_main_web_app: Optional[bool] = None, + has_topics_enabled: Optional[bool] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -200,6 +210,7 @@ def __init__( self.added_to_attachment_menu: Optional[bool] = added_to_attachment_menu self.can_connect_to_business: Optional[bool] = can_connect_to_business self.has_main_web_app: Optional[bool] = has_main_web_app + self.has_topics_enabled: Optional[bool] = has_topics_enabled self._id_attrs = (self.id,) diff --git a/tests/test_user.py b/tests/test_user.py index 490aa6052ec..b27403e14f4 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -44,6 +44,7 @@ def json_dict(): "added_to_attachment_menu": UserTestBase.added_to_attachment_menu, "can_connect_to_business": UserTestBase.can_connect_to_business, "has_main_web_app": UserTestBase.has_main_web_app, + "has_topics_enabled": UserTestBase.has_topics_enabled, } @@ -63,6 +64,7 @@ def user(bot): added_to_attachment_menu=UserTestBase.added_to_attachment_menu, can_connect_to_business=UserTestBase.can_connect_to_business, has_main_web_app=UserTestBase.has_main_web_app, + has_topics_enabled=UserTestBase.has_topics_enabled, ) user.set_bot(bot) user._unfreeze() @@ -83,6 +85,7 @@ class UserTestBase: added_to_attachment_menu = False can_connect_to_business = True has_main_web_app = False + has_topics_enabled = False class TestUserWithoutRequest(UserTestBase): @@ -108,6 +111,7 @@ def test_de_json(self, json_dict, offline_bot): assert user.added_to_attachment_menu == self.added_to_attachment_menu assert user.can_connect_to_business == self.can_connect_to_business assert user.has_main_web_app == self.has_main_web_app + assert user.has_topics_enabled == self.has_topics_enabled def test_to_dict(self, user): user_dict = user.to_dict() @@ -126,6 +130,7 @@ def test_to_dict(self, user): assert user_dict["added_to_attachment_menu"] == user.added_to_attachment_menu assert user_dict["can_connect_to_business"] == user.can_connect_to_business assert user_dict["has_main_web_app"] == user.has_main_web_app + assert user_dict["has_topics_enabled"] == user.has_topics_enabled def test_equality(self): a = User(self.id_, self.first_name, self.is_bot, self.last_name) From f0efbb9454efacb10c5e4199b0ba590c0aea1b5e Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Thu, 16 Oct 2025 02:58:06 +0300 Subject: [PATCH 02/11] Add field `is_name_implicit` to `Forum{Topic,TopicCreated}` --- src/telegram/_forumtopic.py | 30 ++++++++++++++-- tests/test_forum.py | 68 ++++++++++++++++++++++++++----------- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/src/telegram/_forumtopic.py b/src/telegram/_forumtopic.py index b9e5436f17c..c5b923f6765 100644 --- a/src/telegram/_forumtopic.py +++ b/src/telegram/_forumtopic.py @@ -40,6 +40,10 @@ class ForumTopic(TelegramObject): icon_color (:obj:`int`): Color of the topic icon in RGB format icon_custom_emoji_id (:obj:`str`, optional): Unique identifier of the custom emoji shown as the topic icon. + is_name_implicit (:obj:`bool`, optional): :obj:`True`, if the name of the topic wasn't + specified explicitly by its creator and likely needs to be changed by the bot. + + .. versionadded:: NEXT.VERSION Attributes: message_thread_id (:obj:`int`): Unique identifier of the forum topic @@ -47,9 +51,19 @@ class ForumTopic(TelegramObject): icon_color (:obj:`int`): Color of the topic icon in RGB format icon_custom_emoji_id (:obj:`str`): Optional. Unique identifier of the custom emoji shown as the topic icon. + is_name_implicit (:obj:`bool`): Optional. :obj:`True`, if the name of the topic wasn't + specified explicitly by its creator and likely needs to be changed by the bot. + + .. versionadded:: NEXT.VERSION """ - __slots__ = ("icon_color", "icon_custom_emoji_id", "message_thread_id", "name") + __slots__ = ( + "icon_color", + "icon_custom_emoji_id", + "is_name_implicit", + "message_thread_id", + "name", + ) def __init__( self, @@ -57,6 +71,7 @@ def __init__( name: str, icon_color: int, icon_custom_emoji_id: Optional[str] = None, + is_name_implicit: Optional[bool] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -65,6 +80,7 @@ def __init__( self.name: str = name self.icon_color: int = icon_color self.icon_custom_emoji_id: Optional[str] = icon_custom_emoji_id + self.is_name_implicit: Optional[bool] = is_name_implicit self._id_attrs = (self.message_thread_id, self.name, self.icon_color) @@ -86,21 +102,30 @@ class ForumTopicCreated(TelegramObject): icon_color (:obj:`int`): Color of the topic icon in RGB format icon_custom_emoji_id (:obj:`str`, optional): Unique identifier of the custom emoji shown as the topic icon. + is_name_implicit (:obj:`bool`, optional): :obj:`True`, if the name of the topic wasn't + specified explicitly by its creator and likely needs to be changed by the bot. + + .. versionadded:: NEXT.VERSION Attributes: name (:obj:`str`): Name of the topic icon_color (:obj:`int`): Color of the topic icon in RGB format icon_custom_emoji_id (:obj:`str`): Optional. Unique identifier of the custom emoji shown as the topic icon. + is_name_implicit (:obj:`bool`): Optional. :obj:`True`, if the name of the topic wasn't + specified explicitly by its creator and likely needs to be changed by the bot. + + .. versionadded:: NEXT.VERSION """ - __slots__ = ("icon_color", "icon_custom_emoji_id", "name") + __slots__ = ("icon_color", "icon_custom_emoji_id", "is_name_implicit", "name") def __init__( self, name: str, icon_color: int, icon_custom_emoji_id: Optional[str] = None, + is_name_implicit: Optional[bool] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -108,6 +133,7 @@ def __init__( self.name: str = name self.icon_color: int = icon_color self.icon_custom_emoji_id: Optional[str] = icon_custom_emoji_id + self.is_name_implicit: Optional[bool] = is_name_implicit self._id_attrs = (self.name, self.icon_color) diff --git a/tests/test_forum.py b/tests/test_forum.py index dc627eb8462..85f9b3a9db0 100644 --- a/tests/test_forum.py +++ b/tests/test_forum.py @@ -40,13 +40,20 @@ async def forum_topic_object(forum_group_id, emoji_id): return ForumTopic( message_thread_id=forum_group_id, - name=TEST_TOPIC_NAME, - icon_color=TEST_TOPIC_ICON_COLOR, + name=ForumTopicTestBase.TEST_TOPIC_NAME, + icon_color=ForumTopicTestBase.TEST_TOPIC_ICON_COLOR, icon_custom_emoji_id=emoji_id, + is_name_implicit=ForumTopicTestBase.is_name_implicit, ) -class TestForumTopicWithoutRequest: +class ForumTopicTestBase: + TEST_TOPIC_NAME = TEST_TOPIC_NAME + TEST_TOPIC_ICON_COLOR = TEST_TOPIC_ICON_COLOR + is_name_implicit = False + + +class TestForumTopicWithoutRequest(ForumTopicTestBase): def test_slot_behaviour(self, forum_topic_object): inst = forum_topic_object for attr in inst.__slots__: @@ -55,33 +62,37 @@ def test_slot_behaviour(self, forum_topic_object): async def test_expected_values(self, emoji_id, forum_group_id, forum_topic_object): assert forum_topic_object.message_thread_id == forum_group_id - assert forum_topic_object.icon_color == TEST_TOPIC_ICON_COLOR - assert forum_topic_object.name == TEST_TOPIC_NAME + assert forum_topic_object.icon_color == self.TEST_TOPIC_ICON_COLOR + assert forum_topic_object.name == self.TEST_TOPIC_NAME assert forum_topic_object.icon_custom_emoji_id == emoji_id + assert forum_topic_object.is_name_implicit == self.is_name_implicit def test_de_json(self, offline_bot, emoji_id, forum_group_id): json_dict = { "message_thread_id": forum_group_id, - "name": TEST_TOPIC_NAME, - "icon_color": TEST_TOPIC_ICON_COLOR, + "name": self.TEST_TOPIC_NAME, + "icon_color": self.TEST_TOPIC_ICON_COLOR, "icon_custom_emoji_id": emoji_id, + "is_name_implicit": self.is_name_implicit, } topic = ForumTopic.de_json(json_dict, offline_bot) assert topic.api_kwargs == {} assert topic.message_thread_id == forum_group_id - assert topic.icon_color == TEST_TOPIC_ICON_COLOR - assert topic.name == TEST_TOPIC_NAME + assert topic.icon_color == self.TEST_TOPIC_ICON_COLOR + assert topic.name == self.TEST_TOPIC_NAME assert topic.icon_custom_emoji_id == emoji_id + assert topic.is_name_implicit == self.is_name_implicit def test_to_dict(self, emoji_id, forum_group_id, forum_topic_object): topic_dict = forum_topic_object.to_dict() assert isinstance(topic_dict, dict) assert topic_dict["message_thread_id"] == forum_group_id - assert topic_dict["name"] == TEST_TOPIC_NAME - assert topic_dict["icon_color"] == TEST_TOPIC_ICON_COLOR + assert topic_dict["name"] == self.TEST_TOPIC_NAME + assert topic_dict["icon_color"] == self.TEST_TOPIC_ICON_COLOR assert topic_dict["icon_custom_emoji_id"] == emoji_id + assert topic_dict["is_name_implicit"] == self.is_name_implicit def test_equality(self, emoji_id, forum_group_id): a = ForumTopic( @@ -289,10 +300,20 @@ async def test_close_reopen_hide_unhide_general_forum_topic(self, bot, forum_gro @pytest.fixture(scope="module") def topic_created(): - return ForumTopicCreated(name=TEST_TOPIC_NAME, icon_color=TEST_TOPIC_ICON_COLOR) + return ForumTopicCreated( + name=ForumTopicCreatedTestBase.TEST_TOPIC_NAME, + icon_color=ForumTopicCreatedTestBase.TEST_TOPIC_ICON_COLOR, + is_name_implicit=ForumTopicCreatedTestBase.is_name_implicit, + ) + + +class ForumTopicCreatedTestBase: + TEST_TOPIC_NAME = TEST_TOPIC_NAME + TEST_TOPIC_ICON_COLOR = TEST_TOPIC_ICON_COLOR + is_name_implicit = False -class TestForumTopicCreatedWithoutRequest: +class TestForumTopicCreatedWithoutRequest(ForumTopicCreatedTestBase): def test_slot_behaviour(self, topic_created): for attr in topic_created.__slots__: assert getattr(topic_created, attr, "err") != "err", f"got extra slot '{attr}'" @@ -301,23 +322,30 @@ def test_slot_behaviour(self, topic_created): ) def test_expected_values(self, topic_created): - assert topic_created.icon_color == TEST_TOPIC_ICON_COLOR - assert topic_created.name == TEST_TOPIC_NAME + assert topic_created.icon_color == self.TEST_TOPIC_ICON_COLOR + assert topic_created.name == self.TEST_TOPIC_NAME + assert topic_created.is_name_implicit == self.is_name_implicit def test_de_json(self, offline_bot): - json_dict = {"icon_color": TEST_TOPIC_ICON_COLOR, "name": TEST_TOPIC_NAME} + json_dict = { + "icon_color": self.TEST_TOPIC_ICON_COLOR, + "name": self.TEST_TOPIC_NAME, + "is_name_implicit": self.is_name_implicit, + } action = ForumTopicCreated.de_json(json_dict, offline_bot) assert action.api_kwargs == {} - assert action.icon_color == TEST_TOPIC_ICON_COLOR - assert action.name == TEST_TOPIC_NAME + assert action.icon_color == self.TEST_TOPIC_ICON_COLOR + assert action.name == self.TEST_TOPIC_NAME + assert action.is_name_implicit == self.is_name_implicit def test_to_dict(self, topic_created): action_dict = topic_created.to_dict() assert isinstance(action_dict, dict) - assert action_dict["name"] == TEST_TOPIC_NAME - assert action_dict["icon_color"] == TEST_TOPIC_ICON_COLOR + assert action_dict["name"] == self.TEST_TOPIC_NAME + assert action_dict["icon_color"] == self.TEST_TOPIC_ICON_COLOR + assert action_dict["is_name_implicit"] == self.is_name_implicit def test_equality(self, emoji_id): a = ForumTopicCreated(name=TEST_TOPIC_NAME, icon_color=TEST_TOPIC_ICON_COLOR) From 34d5027c5b56a889490b84088aab22db73a99bf4 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Thu, 16 Oct 2025 03:58:54 +0300 Subject: [PATCH 03/11] Update docstrings of `{edit,delete,unpin_all}_forum_topic[_messages]` - `edit_forum_topic` - `delete_forum_topic` - `unpin_all_forum_topic_messages` --- src/telegram/_bot.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/telegram/_bot.py b/src/telegram/_bot.py index 1a126c4127f..8d78956de89 100644 --- a/src/telegram/_bot.py +++ b/src/telegram/_bot.py @@ -8893,8 +8893,9 @@ async def edit_forum_topic( api_kwargs: Optional[JSONDict] = None, ) -> bool: """ - Use this method to edit name and icon of a topic in a forum supergroup chat. The bot must - be an administrator in the chat for this to work and must have the + Use this method to edit name and icon of a topic in a forum supergroup chat or a private + chat with a user. In the case of a supergroup chat the bot must be an administrator in the + chat for this to work and must have the :paramref:`~telegram.ChatAdministratorRights.can_manage_topics` administrator rights, unless it is the creator of the topic. @@ -9036,7 +9037,8 @@ async def delete_forum_topic( ) -> bool: """ Use this method to delete a forum topic along with all its messages in a forum supergroup - chat. The bot must be an administrator in the chat for this to work and must have + chat or a private chat with a user. In the case of a supergroup chat the bot must be an + administrator in the chat for this to work and must have the :paramref:`~telegram.ChatAdministratorRights.can_delete_messages` administrator rights. .. versionadded:: 20.0 @@ -9078,10 +9080,11 @@ async def unpin_all_forum_topic_messages( api_kwargs: Optional[JSONDict] = None, ) -> bool: """ - Use this method to clear the list of pinned messages in a forum topic. The bot must - be an administrator in the chat for this to work and must have - :paramref:`~telegram.ChatAdministratorRights.can_pin_messages` administrator rights - in the supergroup. + Use this method to clear the list of pinned messages in a forum topic in a forum supergroup + chat or a private chat with a user. In the case of a supergroup chat the bot must be an + administrator in the chat for this to work and must have the + :paramref:`~telegram.ChatAdministratorRights.can_pin_messages` administrator right in + the supergroup. .. versionadded:: 20.0 From 3358de3b8a64a51b9fbbba98673cffa17fa40bad Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Thu, 16 Oct 2025 04:09:40 +0300 Subject: [PATCH 04/11] Update `Message` docstring --- src/telegram/_message.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/telegram/_message.py b/src/telegram/_message.py index 65cc7cc61e4..fe475d601ec 100644 --- a/src/telegram/_message.py +++ b/src/telegram/_message.py @@ -499,12 +499,12 @@ class Message(MaybeInaccessibleMessage): reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): Inline keyboard attached to the message. :paramref:`~telegram.InlineKeyboardButton.login_url` buttons are represented as ordinary url buttons. - is_topic_message (:obj:`bool`, optional): :obj:`True`, if the message is sent to a forum - topic. + is_topic_message (:obj:`bool`, optional): :obj:`True`, if the message is sent to a topic + in a forum supergroup or a private chat with the bot. .. versionadded:: 20.0 - message_thread_id (:obj:`int`, optional): Unique identifier of a message thread to which - the message belongs; for supergroups only. + message_thread_id (:obj:`int`, optional): Unique identifier of a message thread or forum + topic to which the message belongs; for supergroups and private chats only. .. versionadded:: 20.0 forum_topic_created (:class:`telegram.ForumTopicCreated`, optional): Service message: @@ -901,12 +901,12 @@ class Message(MaybeInaccessibleMessage): reply_markup (:class:`telegram.InlineKeyboardMarkup`): Optional. Inline keyboard attached to the message. :paramref:`~telegram.InlineKeyboardButton.login_url` buttons are represented as ordinary url buttons. - is_topic_message (:obj:`bool`): Optional. :obj:`True`, if the message is sent to a forum - topic. + is_topic_message (:obj:`bool`): Optional. :obj:`True`, if the message is sent to a topic + in a forum supergroup or a private chat with the bot. .. versionadded:: 20.0 - message_thread_id (:obj:`int`): Optional. Unique identifier of a message thread to which - the message belongs; for supergroups only. + message_thread_id (:obj:`int`): Optional. Unique identifier of a message thread or forum + topic to which the message belongs; for supergroups and private chats only. .. versionadded:: 20.0 forum_topic_created (:class:`telegram.ForumTopicCreated`): Optional. Service message: From a5555aa8d428dabd2a4a3656ffea1d3f9da94401 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Thu, 16 Oct 2025 04:25:03 +0300 Subject: [PATCH 05/11] Update docstring of `send_*.param.message_thread_id` --- docs/substitutions/global.rst | 2 +- src/telegram/_bot.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/substitutions/global.rst b/docs/substitutions/global.rst index fe7b976f5b7..f1bad363716 100644 --- a/docs/substitutions/global.rst +++ b/docs/substitutions/global.rst @@ -30,7 +30,7 @@ .. |message_thread_id| replace:: Unique identifier for the target message thread of the forum topic. -.. |message_thread_id_arg| replace:: Unique identifier for the target message thread (topic) of the forum; for forum supergroups only. +.. |message_thread_id_arg| replace:: Unique identifier for the target message thread (topic) of a forum; for forum supergroups and private chats of bots with forum topic mode enabled only. .. |parse_mode| replace:: Mode for parsing entities. See :class:`telegram.constants.ParseMode` and `formatting options `__ for more details. diff --git a/src/telegram/_bot.py b/src/telegram/_bot.py index 8d78956de89..425cec1c688 100644 --- a/src/telegram/_bot.py +++ b/src/telegram/_bot.py @@ -3718,7 +3718,9 @@ async def send_chat_action( action(:obj:`str`): Type of action to broadcast. Choose one, depending on what the user is about to receive. For convenience look at the constants in :class:`telegram.constants.ChatAction`. - message_thread_id (:obj:`int`, optional): |message_thread_id_arg| + message_thread_id (:obj:`int`, optional): Unique identifier for the target message + thread or topic of a forum; for supergroups and private chats of bots with forum + topic mode enabled only .. versionadded:: 20.0 business_connection_id (:obj:`str`, optional): |business_id_str| From ac548b9a4a3ad22825d6ba1ed8f4c952fe9b8f71 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Sun, 14 Dec 2025 17:18:53 +0200 Subject: [PATCH 06/11] Add method `send_message_draft` and shortcuts --- src/telegram/_bot.py | 56 +++++++++++++++++++++++++++++++++++++ src/telegram/_chat.py | 36 ++++++++++++++++++++++++ src/telegram/_message.py | 51 +++++++++++++++++++++++++++++++-- src/telegram/ext/_extbot.py | 29 +++++++++++++++++++ 4 files changed, 169 insertions(+), 3 deletions(-) diff --git a/src/telegram/_bot.py b/src/telegram/_bot.py index 425cec1c688..9986d4738fa 100644 --- a/src/telegram/_bot.py +++ b/src/telegram/_bot.py @@ -1197,6 +1197,60 @@ async def delete_message( api_kwargs=api_kwargs, ) + async def send_message_draft( + self, + chat_id: int, + text: str, + message_thread_id: Optional[int] = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + entities: Optional[Sequence["MessageEntity"]] = 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 stream a partial message to a user while the message is being + generated; supported only for bots with forum topic mode enabled. + + .. versionadded:: NEXT.VERSION + + Args: + chat_id (:obj:`int`): Unique identifier for the target private chat. + text (:obj:`str`): Text of the message to be sent. Max + :tg-const:`telegram.constants.MessageLimit.MAX_TEXT_LENGTH` characters after + entities parsing. + parse_mode (:obj:`str`): |parse_mode| + entities (Sequence[:class:`telegram.MessageEntity`], optional): Sequence of special + entities that appear in message text, which can be specified instead of + :paramref:`parse_mode`. + + |sequenceargs| + message_thread_id (:obj:`int`, optional): Unique identifier for the target + message thread. + + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + + """ + data: JSONDict = {"chat_id": chat_id, "text": text, "entities": entities} + return await self._send_message( + "sendMessageDraft", + data, + message_thread_id=message_thread_id, + parse_mode=parse_mode, + 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, chat_id: Union[int, str], @@ -11586,6 +11640,8 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """Alias for :meth:`get_me`""" sendMessage = send_message """Alias for :meth:`send_message`""" + sendMessageDraft = send_message_draft + """Alias for :meth:`send_message_draft`""" deleteMessage = delete_message """Alias for :meth:`delete_message`""" deleteMessages = delete_messages diff --git a/src/telegram/_chat.py b/src/telegram/_chat.py index fab9f896c0c..e7b1543caa8 100644 --- a/src/telegram/_chat.py +++ b/src/telegram/_chat.py @@ -1080,6 +1080,42 @@ async def send_message( suggested_post_parameters=suggested_post_parameters, ) + async def send_message_draft( + self, + text: str, + message_thread_id: Optional[int] = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + entities: Optional[Sequence["MessageEntity"]] = 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.send_message_draft(update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_message_draft`. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().send_message_draft( + chat_id=self.id, + text=text, + message_thread_id=message_thread_id, + parse_mode=parse_mode, + entities=entities, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def delete_message( self, message_id: int, diff --git a/src/telegram/_message.py b/src/telegram/_message.py index fe475d601ec..d1e2e1594df 100644 --- a/src/telegram/_message.py +++ b/src/telegram/_message.py @@ -1986,9 +1986,9 @@ def _parse_message_thread_id( return message_thread_id # self.message_thread_id can be used for send_*.param.message_thread_id only if the - # thread is a forum topic. It does not work if the thread is a chain of replies to a - # message in a normal group. In that case, self.message_thread_id is just the message_id - # of the first message in the chain. + # thread is a forum topic (in supergroups or private chats). It does not work if the + # thread is a chain of replies to a message in a normal group. In that case, + # self.message_thread_id is just the message_id of the first message in the chain. if not self.is_topic_message: return None @@ -2081,6 +2081,51 @@ async def reply_text( suggested_post_parameters=suggested_post_parameters, ) + async def reply_text_draft( + self, + text: str, + parse_mode: ODVInput[str] = DEFAULT_NONE, + entities: Optional[Sequence["MessageEntity"]] = None, + message_thread_id: ODVInput[int] = DEFAULT_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.send_message_draft( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_message_draft`. + + Note: + |reply_same_thread| + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + message_thread_id = self._parse_message_thread_id(self.chat_id, message_thread_id) + return await self.get_bot().send_message_draft( + chat_id=self.chat_id, + text=text, + parse_mode=parse_mode, + entities=entities, + 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 reply_markdown( self, text: str, diff --git a/src/telegram/ext/_extbot.py b/src/telegram/ext/_extbot.py index a676e4b6001..1a8b55e494e 100644 --- a/src/telegram/ext/_extbot.py +++ b/src/telegram/ext/_extbot.py @@ -3130,6 +3130,34 @@ async def send_message( suggested_post_parameters=suggested_post_parameters, ) + async def send_message_draft( + self, + chat_id: int, + text: str, + message_thread_id: Optional[int] = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + entities: Optional[Sequence["MessageEntity"]] = 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().send_message_draft( + chat_id=chat_id, + text=text, + message_thread_id=message_thread_id, + parse_mode=parse_mode, + entities=entities, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + async def send_photo( self, chat_id: Union[int, str], @@ -5277,6 +5305,7 @@ async def approve_suggested_post( # updated camelCase aliases getMe = get_me sendMessage = send_message + sendMessageDraft = send_message_draft deleteMessage = delete_message deleteMessages = delete_messages forwardMessage = forward_message From fde7ac77c32609e47e068931a42665ef2323f4b6 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Mon, 15 Dec 2025 21:24:08 +0200 Subject: [PATCH 07/11] Update revised stuff on `bot.send_message_draft` --- src/telegram/_bot.py | 13 +++++++++++-- src/telegram/_chat.py | 2 ++ src/telegram/_message.py | 2 ++ src/telegram/constants.py | 15 ++++++++++----- src/telegram/ext/_extbot.py | 2 ++ 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/telegram/_bot.py b/src/telegram/_bot.py index 9986d4738fa..effee659abf 100644 --- a/src/telegram/_bot.py +++ b/src/telegram/_bot.py @@ -1200,6 +1200,7 @@ async def delete_message( async def send_message_draft( self, chat_id: int, + draft_id: int, text: str, message_thread_id: Optional[int] = None, parse_mode: ODVInput[str] = DEFAULT_NONE, @@ -1218,7 +1219,10 @@ async def send_message_draft( Args: chat_id (:obj:`int`): Unique identifier for the target private chat. - text (:obj:`str`): Text of the message to be sent. Max + draft_id (:obj:`int`): Unique identifier of the message draft; must be non-zero. + Changes of drafts with the same identifier are animated. + text (:obj:`str`): Text of the message to be sent, + :tg-const:`telegram.constants.MessageLimit.MIN_TEXT_LENGTH`- :tg-const:`telegram.constants.MessageLimit.MAX_TEXT_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): |parse_mode| @@ -1238,7 +1242,12 @@ async def send_message_draft( :class:`telegram.error.TelegramError` """ - data: JSONDict = {"chat_id": chat_id, "text": text, "entities": entities} + data: JSONDict = { + "chat_id": chat_id, + "draft_id": draft_id, + "text": text, + "entities": entities, + } return await self._send_message( "sendMessageDraft", data, diff --git a/src/telegram/_chat.py b/src/telegram/_chat.py index e7b1543caa8..0b2f11f6255 100644 --- a/src/telegram/_chat.py +++ b/src/telegram/_chat.py @@ -1082,6 +1082,7 @@ async def send_message( async def send_message_draft( self, + draft_id: int, text: str, message_thread_id: Optional[int] = None, parse_mode: ODVInput[str] = DEFAULT_NONE, @@ -1105,6 +1106,7 @@ async def send_message_draft( """ return await self.get_bot().send_message_draft( chat_id=self.id, + draft_id=draft_id, text=text, message_thread_id=message_thread_id, parse_mode=parse_mode, diff --git a/src/telegram/_message.py b/src/telegram/_message.py index d1e2e1594df..3fd47e2cfa9 100644 --- a/src/telegram/_message.py +++ b/src/telegram/_message.py @@ -2083,6 +2083,7 @@ async def reply_text( async def reply_text_draft( self, + draft_id: int, text: str, parse_mode: ODVInput[str] = DEFAULT_NONE, entities: Optional[Sequence["MessageEntity"]] = None, @@ -2115,6 +2116,7 @@ async def reply_text_draft( message_thread_id = self._parse_message_thread_id(self.chat_id, message_thread_id) return await self.get_bot().send_message_draft( chat_id=self.chat_id, + draft_id=draft_id, text=text, parse_mode=parse_mode, entities=entities, diff --git a/src/telegram/constants.py b/src/telegram/constants.py index 6739b3c282e..c656073520a 100644 --- a/src/telegram/constants.py +++ b/src/telegram/constants.py @@ -2010,6 +2010,8 @@ class MessageLimit(IntEnum): * :paramref:`~telegram.Bot.send_message.text` parameter of :meth:`telegram.Bot.send_message` * :paramref:`~telegram.Bot.edit_message_text.text` parameter of :meth:`telegram.Bot.edit_message_text` + * :paramref:`~telegram.Bot.send_message_draft.text` parameter of + :meth:`telegram.Bot.send_message_draft` """ CAPTION_LENGTH = 1024 """:obj:`int`: Maximum number of characters in a :obj:`str` passed as: @@ -2025,11 +2027,14 @@ class MessageLimit(IntEnum): """ # constants above this line are tested MIN_TEXT_LENGTH = 1 - """:obj:`int`: Minimum number of characters in a :obj:`str` passed as the - :paramref:`~telegram.InputTextMessageContent.message_text` parameter of - :class:`telegram.InputTextMessageContent` and the - :paramref:`~telegram.Bot.edit_message_text.text` parameter of - :meth:`telegram.Bot.edit_message_text`. + """:obj:`int`: Minimum number of characters in a :obj:`str` passed as: + + * :paramref:`~telegram.InputTextMessageContent.message_text` parameter of + :class:`telegram.InputTextMessageContent`. + * :paramref:`~telegram.Bot.edit_message_text.text` parameter of + :meth:`telegram.Bot.edit_message_text`. + * :paramref:`~telegram.Bot.send_message_draft.text` parameter of + :meth:`telegram.Bot.send_message_draft`. """ DEEP_LINK_LENGTH = 64 """:obj:`int`: Maximum number of characters for a deep link.""" diff --git a/src/telegram/ext/_extbot.py b/src/telegram/ext/_extbot.py index 1a8b55e494e..a7b6f3a32a9 100644 --- a/src/telegram/ext/_extbot.py +++ b/src/telegram/ext/_extbot.py @@ -3133,6 +3133,7 @@ async def send_message( async def send_message_draft( self, chat_id: int, + draft_id: int, text: str, message_thread_id: Optional[int] = None, parse_mode: ODVInput[str] = DEFAULT_NONE, @@ -3147,6 +3148,7 @@ async def send_message_draft( ) -> bool: return await super().send_message_draft( chat_id=chat_id, + draft_id=draft_id, text=text, message_thread_id=message_thread_id, parse_mode=parse_mode, From ac9eb7473fda3c8943d7fb8984ec64946cb9c5b4 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Mon, 15 Dec 2025 21:31:08 +0200 Subject: [PATCH 08/11] Add `send_message_draft` tests --- tests/test_bot.py | 55 +++++++++++++++++++++++++++++++++++++++++++ tests/test_chat.py | 15 ++++++++++++ tests/test_message.py | 34 +++++++++++++++++++++++++- 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/tests/test_bot.py b/tests/test_bot.py index fc688456721..927b94997f2 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -1424,6 +1424,61 @@ async def make_assertion(*args, **_): "SoSecretToken", ) + async def test_send_message_draft(self, offline_bot, monkeypatch): + entities = [ + MessageEntity(MessageEntity.BOLD, 0, 3), + MessageEntity(MessageEntity.ITALIC, 5, 8), + ] + + async def make_assertions(*args, **kwargs): + params = kwargs.get("request_data").parameters + assert params.get("chat_id") == 123 + assert params.get("draft_id") == 1 + assert params.get("text") == "test test" + assert params.get("message_thread_id") == 9 + assert params.get("parse_mode") == "markdown" + assert params.get("entities") == [e.to_dict() for e in entities] + + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertions) + assert await offline_bot.send_message_draft( + chat_id=123, + draft_id=1, + text="test test", + message_thread_id=9, + parse_mode="markdown", + entities=entities, + ) + + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) + @pytest.mark.parametrize( + ("passed_value", "expected_value"), + [(DEFAULT_NONE, "Markdown"), ("HTML", "HTML"), (None, None)], + ) + async def test_send_message_draft_default_parse_mode( + self, default_bot, monkeypatch, passed_value, expected_value + ): + async def make_assertion(url, request_data, *args, **kwargs): + assert request_data.parameters.get("parse_mode") == expected_value + return True + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + kwargs = { + "chat_id": 123, + "draft_id": 1, + "text": "test test", + "message_thread_id": 9, + "entities": [ + MessageEntity(MessageEntity.BOLD, 0, 3), + MessageEntity(MessageEntity.ITALIC, 5, 8), + ], + } + if passed_value is not DEFAULT_NONE: + kwargs["parse_mode"] = passed_value + + await default_bot.send_message_draft(**kwargs) + # TODO: Needs improvement. Need incoming shipping queries to test async def test_answer_shipping_query_ok(self, monkeypatch, offline_bot): # For now just test that our internals pass the correct data diff --git a/tests/test_chat.py b/tests/test_chat.py index 7ab0c2f1d0f..33bc65ee0e2 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -524,6 +524,21 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(chat.get_bot(), "send_message", make_assertion) assert await chat.send_message(text="test") + async def test_instance_method_send_message_draft(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return kwargs["chat_id"] == chat.id and kwargs["text"] == "test" + + assert check_shortcut_signature( + Chat.send_message_draft, Bot.send_message_draft, ["chat_id"], [] + ) + assert await check_shortcut_call( + chat.send_message_draft, chat.get_bot(), "send_message_draft" + ) + assert await check_defaults_handling(chat.send_message_draft, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "send_message_draft", make_assertion) + assert await chat.send_message_draft(draft_id=1, text="test") + async def test_instance_method_send_media_group(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == chat.id and kwargs["media"] == "test_media_group" diff --git a/tests/test_message.py b/tests/test_message.py index 3bafe22d54f..46d975942f4 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -735,7 +735,8 @@ async def extract_message_thread_id(*args, **kwargs): message_thread_id = await method(*args, message_thread_id=None) assert message_thread_id is None - if bot_method_name == "send_chat_action": + # These methods do not accept `do_quote` as passed below + if bot_method_name in ["send_chat_action", "send_message_draft"]: return message_thread_id = await method( @@ -1831,6 +1832,37 @@ async def make_assertion(*_, **kwargs): message, message.reply_html, "send_message", ["test"], monkeypatch ) + async def test_reply_text_draft(self, monkeypatch, message): + async def make_assertion(*_, **kwargs): + id_ = kwargs["chat_id"] == message.chat_id + text = kwargs["text"] == "test" + return id_ and text + + assert check_shortcut_signature( + Message.reply_text_draft, + Bot.send_message_draft, + ["chat_id"], + [], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_text_draft, + message.get_bot(), + "send_message_draft", + skip_params=[""], + shortcut_kwargs=["chat_id"], + ) + assert await check_defaults_handling( + message.reply_text_draft, message.get_bot(), no_default_kwargs={"message_thread_id"} + ) + + monkeypatch.setattr(message.get_bot(), "send_message_draft", make_assertion) + assert await message.reply_text_draft(draft_id=1, text="test") + + await self.check_thread_id_parsing( + message, message.reply_text_draft, "send_message_draft", [1, "test"], monkeypatch + ) + async def test_reply_media_group(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id From 8da7787026058adfa1c6b8f181324a7ce29db698 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Mon, 15 Dec 2025 21:55:55 +0200 Subject: [PATCH 09/11] Add `send_message_draft` to `bot_methods.rst` --- docs/source/inclusions/bot_methods.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/source/inclusions/bot_methods.rst b/docs/source/inclusions/bot_methods.rst index 128fa26ac5c..0f2202bbbf5 100644 --- a/docs/source/inclusions/bot_methods.rst +++ b/docs/source/inclusions/bot_methods.rst @@ -35,6 +35,8 @@ - Used for sending media grouped together * - :meth:`~telegram.Bot.send_message` - Used for sending text messages + * - :meth:`~telegram.Bot.send_message_draft` + - Used for streaming partial text messages * - :meth:`~telegram.Bot.send_paid_media` - Used for sending paid media to channels * - :meth:`~telegram.Bot.send_photo` From 7b4e69dc426810bc17676822f22891a454fdf83c Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Thu, 1 Jan 2026 19:56:06 +0200 Subject: [PATCH 10/11] Update master chango fragment for PR --- changes/unreleased/5078.FoNwUYLbXQFRebTFhR6UPn.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/changes/unreleased/5078.FoNwUYLbXQFRebTFhR6UPn.toml b/changes/unreleased/5078.FoNwUYLbXQFRebTFhR6UPn.toml index f4caa835de5..f9dcd72bcc1 100644 --- a/changes/unreleased/5078.FoNwUYLbXQFRebTFhR6UPn.toml +++ b/changes/unreleased/5078.FoNwUYLbXQFRebTFhR6UPn.toml @@ -2,4 +2,5 @@ features = "Full Support for Bot API 9.3" pull_requests = [ { uid = "5078", author_uid = "aelkheir", closes_threads = ["5077"] }, + { uid = "5079", author_uid = "aelkheir" }, ] From c11f25e2166abd6710626ab1a5ed64f683548e3c Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Wed, 7 Jan 2026 05:17:57 +0200 Subject: [PATCH 11/11] review: add `User.send_message_draft()` and a missing `NEXT.VERSION` --- src/telegram/_message.py | 2 ++ src/telegram/_user.py | 43 ++++++++++++++++++++++++++++++++++++++++ tests/test_user.py | 19 ++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/src/telegram/_message.py b/src/telegram/_message.py index f3ea49a4659..08c37cf12a5 100644 --- a/src/telegram/_message.py +++ b/src/telegram/_message.py @@ -2105,6 +2105,8 @@ async def reply_text_draft( Note: |reply_same_thread| + .. versionadded:: NEXT.VERSION + Returns: :obj:`bool`: On success, :obj:`True` is returned. diff --git a/src/telegram/_user.py b/src/telegram/_user.py index 84b099160d3..4181d2f8cc8 100644 --- a/src/telegram/_user.py +++ b/src/telegram/_user.py @@ -497,6 +497,49 @@ async def send_message( suggested_post_parameters=suggested_post_parameters, ) + async def send_message_draft( + self, + draft_id: int, + text: str, + message_thread_id: int | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + entities: Sequence["MessageEntity"] | 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.send_message_draft(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_message_draft`. + + Note: + |user_chat_id_note| + + .. versionadded:: NEXT.VERSION + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().send_message_draft( + chat_id=self.id, + draft_id=draft_id, + text=text, + message_thread_id=message_thread_id, + parse_mode=parse_mode, + entities=entities, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def delete_message( self, message_id: int, diff --git a/tests/test_user.py b/tests/test_user.py index b27403e14f4..fcafc6a42d2 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -236,6 +236,25 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(user.get_bot(), "send_message", make_assertion) assert await user.send_message("test") + async def test_instance_method_send_message_draft(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == user.id + and kwargs["draft_id"] == 123 + and kwargs["text"] == "test" + ) + + assert check_shortcut_signature( + User.send_message_draft, Bot.send_message_draft, ["chat_id"], [] + ) + assert await check_shortcut_call( + user.send_message_draft, user.get_bot(), "send_message_draft" + ) + assert await check_defaults_handling(user.send_message_draft, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "send_message_draft", make_assertion) + assert await user.send_message_draft(123, "test") + async def test_instance_method_send_photo(self, monkeypatch, user): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == user.id and kwargs["photo"] == "test_photo"