From a7ff5fb7919ceb8e488980999d6c55aaa6fd2153 Mon Sep 17 00:00:00 2001 From: poolitzer Date: Thu, 7 May 2026 13:47:28 +0200 Subject: [PATCH 1/2] Almost everything in guest mode --- src/telegram/_message.py | 45 ++++++++++++++++++++ src/telegram/_update.py | 86 ++++++++++++++++++++++++--------------- src/telegram/_user.py | 13 ++++++ src/telegram/constants.py | 5 +++ tests/test_message.py | 6 +++ tests/test_update.py | 9 ++++ tests/test_user.py | 5 +++ 7 files changed, 137 insertions(+), 32 deletions(-) diff --git a/src/telegram/_message.py b/src/telegram/_message.py index 8c6626f50a6..9bb361e95f2 100644 --- a/src/telegram/_message.py +++ b/src/telegram/_message.py @@ -707,6 +707,21 @@ class Message(MaybeInaccessibleMessage): managed_bot_created (:class:`telegram.ManagedBotCreated`, optional): Service message: user created a bot that will be managed by the current bot. + .. versionadded:: NEXT.VERSION + guest_bot_caller_user (:class:`telegram.User`, optional): For a message sent by a guest + bot, this is the user whose original message triggered the bot's response. + + .. versionadded:: NEXT.VERSION + guest_bot_caller_chat (:class:`telegram.Chat`, optional): For a message sent by a guest + bot, this is the user whose original message triggered the bot's response. + + .. versionadded:: NEXT.VERSION + guest_query_id (:obj:`str`, optional): The unique identifier for the guest query. Use this + identifier with the method :meth:`telegram.Bot.answer_guest_query` to send a response + message. If non-empty, the message belongs to a chat of the corresponding business + account that is independent from any potential bot chat which might share the same + identifier. + .. versionadded:: NEXT.VERSION Attributes: @@ -1139,6 +1154,21 @@ class Message(MaybeInaccessibleMessage): managed_bot_created (:class:`telegram.ManagedBotCreated`): Optional. Service message: user created a bot that will be managed by the current bot. + .. versionadded:: NEXT.VERSION + guest_bot_caller_user (:class:`telegram.User`): Optional. For a message sent by a guest + bot, this is the user whose original message triggered the bot's response. + + .. versionadded:: NEXT.VERSION + guest_bot_caller_chat (:class:`telegram.Chat`): Optional. For a message sent by a guest + bot, this is the user whose original message triggered the bot's response. + + .. versionadded:: NEXT.VERSION + guest_query_id (:obj:`str`): Optional. The unique identifier for the guest query. Use this + identifier with the method :meth:`telegram.Bot.answer_guest_query` to send a response + message. If non-empty, the message belongs to a chat of the corresponding business + account that is independent from any potential bot chat which might share the same + identifier. + .. versionadded:: NEXT.VERSION .. |custom_emoji_no_md1_support| replace:: Since custom emoji entities are not supported by @@ -1201,6 +1231,9 @@ class Message(MaybeInaccessibleMessage): "giveaway_created", "giveaway_winners", "group_chat_created", + "guest_bot_caller_chat", + "guest_bot_caller_user", + "guest_query_id", "has_media_spoiler", "has_protected_content", "invoice", @@ -1380,6 +1413,9 @@ def __init__( poll_option_deleted: PollOptionDeleted | None = None, reply_to_poll_option_id: str | None = None, managed_bot_created: ManagedBotCreated | None = None, + guest_bot_caller_user: User | None = None, + guest_bot_caller_chat: Chat | None = None, + guest_query_id: str | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -1514,6 +1550,9 @@ def __init__( self.poll_option_deleted: PollOptionDeleted | None = poll_option_deleted self.reply_to_poll_option_id: str | None = reply_to_poll_option_id self.managed_bot_created: ManagedBotCreated | None = managed_bot_created + self.guest_bot_caller_user: User | None = guest_bot_caller_user + self.guest_bot_caller_chat: Chat | None = guest_bot_caller_chat + self.guest_query_id: str | None = guest_query_id self._effective_attachment = DEFAULT_NONE @@ -1743,6 +1782,12 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Message": data["managed_bot_created"] = de_json_optional( data.get("managed_bot_created"), ManagedBotCreated, bot ) + data["guest_bot_caller_user"] = de_json_optional( + data.get("guest_bot_caller_user"), User, bot + ) + data["guest_bot_caller_chat"] = de_json_optional( + data.get("guest_bot_caller_chat"), Chat, bot + ) api_kwargs = {} # This is a deprecated field that TG still returns for backwards compatibility diff --git a/src/telegram/_update.py b/src/telegram/_update.py index a8b8668ad17..0dcc8939d73 100644 --- a/src/telegram/_update.py +++ b/src/telegram/_update.py @@ -168,6 +168,11 @@ class Update(TelegramObject): managed by the bot, or token or owner of a managed bot was changed. .. versionadded:: NEXT.VERSION + guest_message (:class:`telegram.Message`, optional): New guest message. The bot can use + the field :attr:`telegram.Message.guest_query_id` and the method + :meth:`telegram.Bot.answer_guest_query` to send a message in response. + + .. versionadded:: NEXT.VERSION Attributes: @@ -284,6 +289,11 @@ class Update(TelegramObject): managed_bot (:class:`telegram.ManagedBotUpdated`): Optional. A new bot was created to be managed by the bot, or token or owner of a managed bot was changed. + .. versionadded:: NEXT.VERSION + guest_message (:class:`telegram.Message`): Optional. New guest message. The bot can use + the field :attr:`telegram.Message.guest_query_id` and the method + :meth:`telegram.Bot.answerGuestQuery` to send a message in response. + .. versionadded:: NEXT.VERSION """ @@ -304,6 +314,7 @@ class Update(TelegramObject): "edited_business_message", "edited_channel_post", "edited_message", + "guest_message", "inline_query", "managed_bot", "message", @@ -417,6 +428,11 @@ class Update(TelegramObject): MANAGED_BOT: Final[str] = constants.UpdateType.MANAGED_BOT """:const:`telegram.constants.UpdateType.MANAGED_BOT` + .. versionadded:: NEXT.VERSION + """ + GUEST_MESSAGE: Final[str] = constants.UpdateType.GUEST_MESSAGE + """:const:`telegram.constants.UpdateType.GUEST_MESSAGE` + .. versionadded:: NEXT.VERSION """ @@ -452,6 +468,7 @@ def __init__( deleted_business_messages: BusinessMessagesDeleted | None = None, purchased_paid_media: PaidMediaPurchased | None = None, managed_bot: ManagedBotUpdated | None = None, + guest_message: Message | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -483,6 +500,7 @@ def __init__( self.deleted_business_messages: BusinessMessagesDeleted | None = deleted_business_messages self.purchased_paid_media: PaidMediaPurchased | None = purchased_paid_media self.managed_bot: ManagedBotUpdated | None = managed_bot + self.guest_message: Message | None = guest_message self._effective_user: User | None = None self._effective_sender: User | Chat | None = None @@ -507,6 +525,7 @@ def effective_user(self) -> "User | None": * :attr:`removed_chat_boost` * :attr:`message_reaction_count` * :attr:`deleted_business_messages` + * :attr:`guest_message` is present. @@ -518,7 +537,7 @@ def effective_user(self) -> "User | None": This property now also considers :attr:`purchased_paid_media`. .. versionchanged:: NEXT.VERSION - This property now also considers :attr:`managed_bot`. + This property now also considers :attr:`managed_bot`, and :attr:`guest_message`. Example: * If :attr:`message` is present, this will give @@ -531,11 +550,15 @@ def effective_user(self) -> "User | None": user = None - if self.message: - user = self.message.from_user - - elif self.edited_message: - user = self.edited_message.from_user + if message := ( + self.message + or self.edited_message + or self.channel_post + or self.business_message + or self.edited_business_message + or self.guest_message + ): + user = message.from_user elif self.inline_query: user = self.inline_query.from_user @@ -567,12 +590,6 @@ def effective_user(self) -> "User | None": elif self.message_reaction: user = self.message_reaction.user - elif self.business_message: - user = self.business_message.from_user - - elif self.edited_business_message: - user = self.edited_business_message.from_user - elif self.business_connection: user = self.business_connection.user @@ -606,6 +623,9 @@ def effective_sender(self) -> "User | Chat | None": is present. + .. versionchanged:: NEXT.VERSION + This property now also considers :attr:`guest_message`. + Example: * If :attr:`message` is present, this will give either :attr:`telegram.Message.from_user` or :attr:`telegram.Message.sender_chat`. @@ -628,6 +648,7 @@ def effective_sender(self) -> "User | Chat | None": or self.edited_channel_post or self.business_message or self.edited_business_message + or self.guest_message ): sender = message.sender_chat @@ -659,6 +680,9 @@ def effective_chat(self) -> "Chat | None": This property now also considers :attr:`business_message`, :attr:`edited_business_message`, and :attr:`deleted_business_messages`. + .. versionchanged:: NEXT.VERSION + This property now also considers :attr:`guest_message`. + Example: If :attr:`message` is present, this will give :attr:`telegram.Message.chat`. @@ -668,21 +692,21 @@ def effective_chat(self) -> "Chat | None": chat = None - if self.message: - chat = self.message.chat - - elif self.edited_message: - chat = self.edited_message.chat + if message := ( + self.message + or self.edited_message + or self.channel_post + or self.edited_channel_post + or self.business_message + or self.edited_business_message + or self.deleted_business_messages + or self.guest_message + ): + chat = message.chat elif self.callback_query and self.callback_query.message: chat = self.callback_query.message.chat - elif self.channel_post: - chat = self.channel_post.chat - - elif self.edited_channel_post: - chat = self.edited_channel_post.chat - elif self.my_chat_member: chat = self.my_chat_member.chat @@ -704,15 +728,6 @@ def effective_chat(self) -> "Chat | None": elif self.message_reaction_count: chat = self.message_reaction_count.chat - elif self.business_message: - chat = self.business_message.chat - - elif self.edited_business_message: - chat = self.edited_business_message.chat - - elif self.deleted_business_messages: - chat = self.deleted_business_messages.chat - self._effective_chat = chat return chat @@ -729,6 +744,9 @@ def effective_message(self) -> Message | None: This property now also considers :attr:`business_message`, and :attr:`edited_business_message`. + .. versionchanged:: NEXT.VERSION + This property now also considers :attr:`guest_message`. + Tip: This property will only ever return objects of type :class:`telegram.Message` or :obj:`None`, never :class:`telegram.MaybeInaccessibleMessage` or @@ -777,6 +795,9 @@ def effective_message(self) -> Message | None: elif self.edited_business_message: message = self.edited_business_message + elif self.guest_message: + message = self.guest_message + self._effective_message = message return message @@ -833,5 +854,6 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Update": data.get("purchased_paid_media"), PaidMediaPurchased, bot ) data["managed_bot"] = de_json_optional(data.get("managed_bot"), ManagedBotUpdated, bot) + data["guest_message"] = de_json_optional(data.get("guest_message"), Message, bot) return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_user.py b/src/telegram/_user.py index 821f230adb6..8ebef5a4148 100644 --- a/src/telegram/_user.py +++ b/src/telegram/_user.py @@ -128,6 +128,11 @@ class User(TelegramObject): can_manage_bots (:obj:`bool`, optional): :obj:`True`, if other bots can be created to be controlled by the bot. Returned only in :meth:`telegram.Bot.get_me`. + .. versionadded:: NEXT.VERSION + supports_guest_queries (:obj:`bool`, optional): :obj:`True`, if the bot supports guest + queries from chats it is not a member of. Returned only in + :meth:`telegram.Bot.get_me`. + .. versionadded:: NEXT.VERSION Attributes: @@ -172,6 +177,11 @@ class User(TelegramObject): can_manage_bots (:obj:`bool`): Optional. :obj:`True`, if other bots can be created to be controlled by the bot. Returned only in :meth:`telegram.Bot.get_me`. + .. versionadded:: NEXT.VERSION + supports_guest_queries (:obj:`bool`): Optional. :obj:`True`, if the bot supports guest + queries from chats it is not a member of. Returned only in + :meth:`telegram.Bot.get_me`. + .. versionadded:: NEXT.VERSION .. |user_chat_id_note| replace:: This shortcuts build on the assumption that :attr:`User.id` @@ -194,6 +204,7 @@ class User(TelegramObject): "is_premium", "language_code", "last_name", + "supports_guest_queries", "supports_inline_queries", "username", ) @@ -216,6 +227,7 @@ def __init__( has_topics_enabled: bool | None = None, allows_users_to_create_topics: bool | None = None, can_manage_bots: bool | None = None, + supports_guest_queries: bool | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -238,6 +250,7 @@ def __init__( self.has_topics_enabled: bool | None = has_topics_enabled self.allows_users_to_create_topics: bool | None = allows_users_to_create_topics self.can_manage_bots: bool | None = can_manage_bots + self.supports_guest_queries: bool | None = supports_guest_queries self._id_attrs = (self.id,) diff --git a/src/telegram/constants.py b/src/telegram/constants.py index fb0be0b48d2..17d15300961 100644 --- a/src/telegram/constants.py +++ b/src/telegram/constants.py @@ -3620,6 +3620,11 @@ class UpdateType(StringEnum): .. versionadded:: NEXT.VERSION """ + GUEST_MESSAGE = "guest_message" + """:obj:`str`: Updates with :attr:`telegram.Update.guest_message`. + + .. versionadded:: NEXT.VERSION + """ class InvoiceLimit(IntEnum): diff --git a/tests/test_message.py b/tests/test_message.py index 958c59b3109..6f7e9eea491 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -443,6 +443,9 @@ def message(bot): {"poll_option_deleted": PollOptionDeleted(option_persistent_id="abc", option_text="this")}, {"reply_to_poll_option_id": "3123"}, {"managed_bot_created": ManagedBotCreated(bot=User(6, "ManagedBot", True))}, + {"guest_bot_caller_user": User(10, "hm", False)}, + {"guest_bot_caller_chat": Chat(14, "om")}, + {"guest_query_id": "This is a guest_query_id"}, ], ids=[ "reply", @@ -541,6 +544,9 @@ def message(bot): "poll_option_deleted", "reply_to_poll_option_id", "managed_bot_created", + "guest_bot_caller_user", + "guest_bot_caller_chat", + "guest_query_id", ], ) def message_params(bot, request): diff --git a/tests/test_update.py b/tests/test_update.py index 8a8e0c4853b..f6ff5276ff8 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -156,6 +156,13 @@ user=User(1, "creator", True), bot=User(2, "bot", True), ) +guest_message = Message( + 1, + dtm.datetime.utcnow(), + Chat(1, ""), + User(1, "", False), +) + params = [ {"message": message}, @@ -197,6 +204,7 @@ {"edited_business_message": business_message}, {"purchased_paid_media": purchased_paid_media}, {"managed_bot": managed_bot}, + {"guest_message": guest_message}, # Must be last to conform with `ids` below! {"callback_query": CallbackQuery(1, User(1, "", False), "chat")}, ] @@ -226,6 +234,7 @@ "edited_business_message", "purchased_paid_media", "managed_bot", + "guest_message", ) ids = (*all_types, "callback_query_without_message") diff --git a/tests/test_user.py b/tests/test_user.py index 24f8ffe8c8d..33db98ca91e 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -47,6 +47,7 @@ def json_dict(): "has_topics_enabled": UserTestBase.has_topics_enabled, "allows_users_to_create_topics": UserTestBase.allows_users_to_create_topics, "can_manage_bots": UserTestBase.can_manage_bots, + "supports_guest_queries": UserTestBase.supports_guest_queries, } @@ -69,6 +70,7 @@ def user(bot): has_topics_enabled=UserTestBase.has_topics_enabled, allows_users_to_create_topics=UserTestBase.allows_users_to_create_topics, can_manage_bots=UserTestBase.can_manage_bots, + supports_guest_queries=UserTestBase.supports_guest_queries, ) user.set_bot(bot) user._unfreeze() @@ -92,6 +94,7 @@ class UserTestBase: has_topics_enabled = False allows_users_to_create_topics = False can_manage_bots = True + supports_guest_queries = False class TestUserWithoutRequest(UserTestBase): @@ -120,6 +123,7 @@ def test_de_json(self, json_dict, offline_bot): assert user.has_topics_enabled == self.has_topics_enabled assert user.allows_users_to_create_topics == self.allows_users_to_create_topics assert user.can_manage_bots == self.can_manage_bots + assert user.supports_guest_queries == self.supports_guest_queries def test_to_dict(self, user): user_dict = user.to_dict() @@ -141,6 +145,7 @@ def test_to_dict(self, user): assert user_dict["has_topics_enabled"] == user.has_topics_enabled assert user_dict["allows_users_to_create_topics"] == user.allows_users_to_create_topics assert user_dict["can_manage_bots"] == user.can_manage_bots + assert user_dict["supports_guest_queries"] == user.supports_guest_queries def test_equality(self): a = User(self.id_, self.first_name, self.is_bot, self.last_name) From 532e8971166fb52e5cd34d398de4bb1dbe985da8 Mon Sep 17 00:00:00 2001 From: Phil Bazun Date: Tue, 19 May 2026 13:22:38 +0200 Subject: [PATCH 2/2] Complete Bot API 10.0 Guest Mode: SentGuestMessage, answer_guest_query, filter (#5234) --- .../unreleased/5234.FgQx53cjQCCm2d7tEiMR.toml | 5 + docs/source/telegram.at-tree.rst | 1 + docs/source/telegram.sentguestmessage.rst | 6 + src/telegram/__init__.py | 2 + src/telegram/_bot.py | 48 ++++++ src/telegram/_sentguestmessage.py | 54 +++++++ src/telegram/_update.py | 1 - src/telegram/ext/_extbot.py | 24 +++ src/telegram/ext/filters.py | 20 ++- tests/auxil/dummy_objects.py | 2 + tests/ext/test_filters.py | 20 +++ tests/test_bot.py | 145 ++++++++++++++++++ tests/test_constants.py | 3 + tests/test_sentguestmessage.py | 64 ++++++++ 14 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 changes/unreleased/5234.FgQx53cjQCCm2d7tEiMR.toml create mode 100644 docs/source/telegram.sentguestmessage.rst create mode 100644 src/telegram/_sentguestmessage.py create mode 100644 tests/test_sentguestmessage.py diff --git a/changes/unreleased/5234.FgQx53cjQCCm2d7tEiMR.toml b/changes/unreleased/5234.FgQx53cjQCCm2d7tEiMR.toml new file mode 100644 index 00000000000..ba38b0efdad --- /dev/null +++ b/changes/unreleased/5234.FgQx53cjQCCm2d7tEiMR.toml @@ -0,0 +1,5 @@ +features = "Added the class :class:`telegram.SentGuestMessage` and the method :meth:`telegram.Bot.answer_guest_query` as well as the filter :attr:`telegram.ext.filters.UpdateType.GUEST_MESSAGE` for Bot API 10.0 Guest Mode support." +[[pull_requests]] +uid = "5234" +author_uids = ["Phil9l"] +closes_threads = ["5228"] diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index 25d9c269838..dbe6277f01b 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -166,6 +166,7 @@ Available Types telegram.replykeyboardmarkup telegram.replykeyboardremove telegram.replyparameters + telegram.sentguestmessage telegram.sentwebappmessage telegram.shareduser telegram.story diff --git a/docs/source/telegram.sentguestmessage.rst b/docs/source/telegram.sentguestmessage.rst new file mode 100644 index 00000000000..b63e0735029 --- /dev/null +++ b/docs/source/telegram.sentguestmessage.rst @@ -0,0 +1,6 @@ +SentGuestMessage +================ + +.. autoclass:: telegram.SentGuestMessage + :members: + :show-inheritance: diff --git a/src/telegram/__init__.py b/src/telegram/__init__.py index 362f3253198..6bac3cd53ea 100644 --- a/src/telegram/__init__.py +++ b/src/telegram/__init__.py @@ -254,6 +254,7 @@ "RevenueWithdrawalStateSucceeded", "SecureData", "SecureValue", + "SentGuestMessage", "SentWebAppMessage", "SharedUser", "ShippingAddress", @@ -595,6 +596,7 @@ from ._reply import ExternalReplyInfo, ReplyParameters, TextQuote from ._replykeyboardmarkup import ReplyKeyboardMarkup from ._replykeyboardremove import ReplyKeyboardRemove +from ._sentguestmessage import SentGuestMessage from ._sentwebappmessage import SentWebAppMessage from ._shared import ChatShared, SharedUser, UsersShared from ._story import Story diff --git a/src/telegram/_bot.py b/src/telegram/_bot.py index d9d1f83b069..9e902249542 100644 --- a/src/telegram/_bot.py +++ b/src/telegram/_bot.py @@ -89,6 +89,7 @@ from telegram._preparedkeyboardbutton import PreparedKeyboardButton from telegram._reaction import ReactionType, ReactionTypeCustomEmoji, ReactionTypeEmoji from telegram._reply import ReplyParameters +from telegram._sentguestmessage import SentGuestMessage from telegram._sentwebappmessage import SentWebAppMessage from telegram._story import Story from telegram._telegramobject import TelegramObject @@ -5848,6 +5849,51 @@ async def answer_web_app_query( return SentWebAppMessage.de_json(api_result, self) + async def answer_guest_query( + self, + guest_query_id: str, + result: "InlineQueryResult", + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> SentGuestMessage: + """Use this method to reply to a received guest message. + + .. versionadded:: NEXT.VERSION + + Args: + guest_query_id (:obj:`str`): Unique identifier for the query to be answered. + result (:class:`telegram.InlineQueryResult`): An object describing the message to be + sent. + + Returns: + :class:`telegram.SentGuestMessage`: On success, a sent + :class:`telegram.SentGuestMessage` is returned. + + Raises: + :class:`telegram.error.TelegramError` + + """ + data: JSONDict = { + "guest_query_id": guest_query_id, + "result": self._insert_defaults_for_ilq_results(result), + } + + api_result = await self._post( + "answerGuestQuery", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + return SentGuestMessage.de_json(api_result, self) + async def restrict_chat_member( self, chat_id: str | int, @@ -12399,6 +12445,8 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """Alias for :meth:`answer_pre_checkout_query`""" answerWebAppQuery = answer_web_app_query """Alias for :meth:`answer_web_app_query`""" + answerGuestQuery = answer_guest_query + """Alias for :meth:`answer_guest_query`""" restrictChatMember = restrict_chat_member """Alias for :meth:`restrict_chat_member`""" promoteChatMember = promote_chat_member diff --git a/src/telegram/_sentguestmessage.py b/src/telegram/_sentguestmessage.py new file mode 100644 index 00000000000..ecbc6ce6fe6 --- /dev/null +++ b/src/telegram/_sentguestmessage.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object that represents a Telegram Sent Guest Message.""" + +from telegram._telegramobject import TelegramObject +from telegram._utils.types import JSONDict + + +class SentGuestMessage(TelegramObject): + """Describes an inline message sent by a guest bot. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`inline_message_id` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + inline_message_id (:obj:`str`): Identifier of the sent inline message. + + Attributes: + inline_message_id (:obj:`str`): Identifier of the sent inline message. + """ + + __slots__ = ("inline_message_id",) + + def __init__( + self, + inline_message_id: str, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.inline_message_id: str = inline_message_id + + self._id_attrs = (self.inline_message_id,) + + self._freeze() diff --git a/src/telegram/_update.py b/src/telegram/_update.py index 0dcc8939d73..bd77163e8d2 100644 --- a/src/telegram/_update.py +++ b/src/telegram/_update.py @@ -553,7 +553,6 @@ def effective_user(self) -> "User | None": if message := ( self.message or self.edited_message - or self.channel_post or self.business_message or self.edited_business_message or self.guest_message diff --git a/src/telegram/ext/_extbot.py b/src/telegram/ext/_extbot.py index 931115f7beb..173b54ee07c 100644 --- a/src/telegram/ext/_extbot.py +++ b/src/telegram/ext/_extbot.py @@ -76,6 +76,7 @@ PreparedKeyboardButton, ReactionType, ReplyParameters, + SentGuestMessage, SentWebAppMessage, StarAmount, StarTransactions, @@ -1119,6 +1120,28 @@ async def answer_web_app_query( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def answer_guest_query( + self, + guest_query_id: str, + result: "InlineQueryResult", + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> SentGuestMessage: + return await super().answer_guest_query( + guest_query_id=guest_query_id, + result=result, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + async def approve_chat_join_request( self, chat_id: str | int, @@ -5647,6 +5670,7 @@ async def save_prepared_keyboard_button( answerShippingQuery = answer_shipping_query answerPreCheckoutQuery = answer_pre_checkout_query answerWebAppQuery = answer_web_app_query + answerGuestQuery = answer_guest_query restrictChatMember = restrict_chat_member promoteChatMember = promote_chat_member setChatPermissions = set_chat_permissions diff --git a/src/telegram/ext/filters.py b/src/telegram/ext/filters.py index d8fa167e7b0..dc4f7549394 100644 --- a/src/telegram/ext/filters.py +++ b/src/telegram/ext/filters.py @@ -273,6 +273,10 @@ def check_update(self, update: Update) -> bool | FilterDataDict | None: :attr:`~telegram.Update.business_message` or :attr:`~telegram.Update.edited_business_message`. + .. versionchanged:: NEXT.VERSION + This filter now also returns :obj:`True` if the update contains + :attr:`~telegram.Update.guest_message`. + Args: update (:class:`telegram.Update`): The update to check. @@ -281,7 +285,8 @@ def check_update(self, update: Update) -> bool | FilterDataDict | None: :attr:`~telegram.Update.channel_post`, :attr:`~telegram.Update.message`, :attr:`~telegram.Update.edited_channel_post`, :attr:`~telegram.Update.edited_message`, :attr:`telegram.Update.business_message`, - :attr:`telegram.Update.edited_business_message`, or :obj:`False` otherwise. + :attr:`telegram.Update.edited_business_message`, + :attr:`telegram.Update.guest_message`, or :obj:`False` otherwise. """ return bool( # Only message updates should be handled. update.channel_post @@ -290,6 +295,7 @@ def check_update(self, update: Update) -> bool | FilterDataDict | None: or update.edited_message or update.business_message or update.edited_business_message + or update.guest_message ) @@ -2894,6 +2900,18 @@ def filter(self, update: Update) -> bool: .. versionadded:: 21.1 """ + class _GuestMessage(UpdateFilter): + __slots__ = () + + def filter(self, update: Update) -> bool: + return update.guest_message is not None + + GUEST_MESSAGE = _GuestMessage(name="filters.UpdateType.GUEST_MESSAGE") + """Updates with :attr:`telegram.Update.guest_message`. + + .. versionadded:: NEXT.VERSION + """ + class User(_ChatUserBaseFilter): """Filters messages to allow only those which are from specified user ID(s) or diff --git a/tests/auxil/dummy_objects.py b/tests/auxil/dummy_objects.py index c0ff0ce0cf5..97999c5188a 100644 --- a/tests/auxil/dummy_objects.py +++ b/tests/auxil/dummy_objects.py @@ -30,6 +30,7 @@ PollOption, PreparedInlineMessage, PreparedKeyboardButton, + SentGuestMessage, SentWebAppMessage, StarAmount, StarTransaction, @@ -136,6 +137,7 @@ ), "PreparedKeyboardButton": PreparedKeyboardButton(id=1234), "PreparedInlineMessage": PreparedInlineMessage(id="dummy_id", expiration_date=_DUMMY_DATE), + "SentGuestMessage": SentGuestMessage(inline_message_id="dummy_inline_message_id"), "SentWebAppMessage": SentWebAppMessage(inline_message_id="dummy_inline_message_id"), "StarAmount": StarAmount(amount=100, nanostar_amount=356), "StarTransactions": StarTransactions( diff --git a/tests/ext/test_filters.py b/tests/ext/test_filters.py index e68fb0dbc09..9c2f192c56f 100644 --- a/tests/ext/test_filters.py +++ b/tests/ext/test_filters.py @@ -2471,6 +2471,7 @@ def test_update_type_message(self, update): assert not filters.UpdateType.BUSINESS_MESSAGES.check_update(update) assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.GUEST_MESSAGE.check_update(update) def test_update_type_edited_message(self, update): update.edited_message, update.message = update.message, update.edited_message @@ -2484,6 +2485,7 @@ def test_update_type_edited_message(self, update): assert not filters.UpdateType.BUSINESS_MESSAGES.check_update(update) assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.GUEST_MESSAGE.check_update(update) def test_update_type_channel_post(self, update): update.channel_post, update.message = update.message, update.edited_message @@ -2497,6 +2499,7 @@ def test_update_type_channel_post(self, update): assert not filters.UpdateType.BUSINESS_MESSAGES.check_update(update) assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.GUEST_MESSAGE.check_update(update) def test_update_type_edited_channel_post(self, update): update.edited_channel_post, update.message = update.message, update.edited_message @@ -2510,6 +2513,7 @@ def test_update_type_edited_channel_post(self, update): assert not filters.UpdateType.BUSINESS_MESSAGES.check_update(update) assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.GUEST_MESSAGE.check_update(update) def test_update_type_business_message(self, update): update.business_message, update.message = update.message, update.edited_message @@ -2523,6 +2527,7 @@ def test_update_type_business_message(self, update): assert filters.UpdateType.BUSINESS_MESSAGES.check_update(update) assert filters.UpdateType.BUSINESS_MESSAGE.check_update(update) assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.GUEST_MESSAGE.check_update(update) def test_update_type_edited_business_message(self, update): update.edited_business_message, update.message = update.message, update.edited_message @@ -2536,6 +2541,21 @@ def test_update_type_edited_business_message(self, update): assert filters.UpdateType.BUSINESS_MESSAGES.check_update(update) assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) assert filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.GUEST_MESSAGE.check_update(update) + + def test_update_type_guest_message(self, update): + update.guest_message, update.message = update.message, update.edited_message + assert not filters.UpdateType.MESSAGE.check_update(update) + assert not filters.UpdateType.EDITED_MESSAGE.check_update(update) + assert not filters.UpdateType.MESSAGES.check_update(update) + assert not filters.UpdateType.CHANNEL_POST.check_update(update) + assert not filters.UpdateType.EDITED_CHANNEL_POST.check_update(update) + assert not filters.UpdateType.CHANNEL_POSTS.check_update(update) + assert not filters.UpdateType.EDITED.check_update(update) + assert not filters.UpdateType.BUSINESS_MESSAGES.check_update(update) + assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert filters.UpdateType.GUEST_MESSAGE.check_update(update) def test_merged_short_circuit_and(self, update, base_class): update.message.text = "/test" diff --git a/tests/test_bot.py b/tests/test_bot.py index 2fd77550ecb..a8514270399 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -76,6 +76,7 @@ ReactionTypeCustomEmoji, ReactionTypeEmoji, ReplyParameters, + SentGuestMessage, SentWebAppMessage, ShippingOption, StarTransaction, @@ -860,6 +861,150 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): == copied_result.input_message_content.parse_mode ) + async def test_answer_guest_query(self, offline_bot, raw_bot, monkeypatch): + params = False + + # For now just test that our internals pass the correct data + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + nonlocal params + params = request_data.parameters == { + "guest_query_id": "12345", + "result": { + "title": "title", + "input_message_content": { + "message_text": "text", + }, + "type": InlineQueryResultType.ARTICLE, + "id": "1", + }, + } + return SentGuestMessage("321").to_dict() + + result = InlineQueryResultArticle("1", "title", InputTextMessageContent("text")) + copied_result = copy.copy(result) + + ext_bot = offline_bot + for bot_type in (ext_bot, raw_bot): + monkeypatch.setattr(bot_type.request, "post", make_assertion) + guest_msg = await bot_type.answer_guest_query("12345", result) + assert params, "something went wrong with passing arguments to the request" + assert isinstance(guest_msg, SentGuestMessage) + assert guest_msg.inline_message_id == "321" + + # make sure that the results were not edited in-place + assert result == copied_result + assert ( + result.input_message_content.parse_mode + == copied_result.input_message_content.parse_mode + ) + + @pytest.mark.parametrize( + "default_bot", + [{"parse_mode": "Markdown", "link_preview_options": LinkPreviewOptions(is_disabled=True)}], + indirect=True, + ) + @pytest.mark.parametrize( + ("ilq_result", "expected_params"), + [ + ( + InlineQueryResultArticle("1", "title", InputTextMessageContent("text")), + { + "guest_query_id": "12345", + "result": { + "title": "title", + "input_message_content": { + "message_text": "text", + "parse_mode": "Markdown", + "link_preview_options": { + "is_disabled": True, + }, + }, + "type": InlineQueryResultType.ARTICLE, + "id": "1", + }, + }, + ), + ( + InlineQueryResultArticle( + "1", + "title", + InputTextMessageContent( + "text", parse_mode="HTML", disable_web_page_preview=False + ), + ), + { + "guest_query_id": "12345", + "result": { + "title": "title", + "input_message_content": { + "message_text": "text", + "parse_mode": "HTML", + "link_preview_options": { + "is_disabled": False, + }, + }, + "type": InlineQueryResultType.ARTICLE, + "id": "1", + }, + }, + ), + ( + InlineQueryResultArticle( + "1", + "title", + InputTextMessageContent( + "text", parse_mode=None, disable_web_page_preview="False" + ), + ), + { + "guest_query_id": "12345", + "result": { + "title": "title", + "input_message_content": { + "message_text": "text", + "link_preview_options": { + "is_disabled": "False", + }, + }, + "type": InlineQueryResultType.ARTICLE, + "id": "1", + }, + }, + ), + ], + ) + async def test_answer_guest_query_defaults( + self, default_bot, ilq_result, expected_params, monkeypatch + ): + offline_bot = default_bot + params = False + + # For now just test that our internals pass the correct data + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + nonlocal params + params = request_data.parameters == expected_params + return SentGuestMessage("321").to_dict() + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + # We test different result types more thoroughly for answer_inline_query, so we just + # use the one type here + copied_result = copy.copy(ilq_result) + + guest_msg = await offline_bot.answer_guest_query("12345", ilq_result) + assert params, "something went wrong with passing arguments to the request" + assert isinstance(guest_msg, SentGuestMessage) + assert guest_msg.inline_message_id == "321" + + # make sure that the results were not edited in-place + assert ilq_result == copied_result + assert ( + ilq_result.input_message_content.parse_mode + == copied_result.input_message_content.parse_mode + ) + # TODO: Needs improvement. We need incoming inline query to test answer. @pytest.mark.parametrize("button_type", ["start", "web_app"]) @pytest.mark.parametrize("cache_time", [74, dtm.timedelta(seconds=74)]) diff --git a/tests/test_constants.py b/tests/test_constants.py index c5e7ff000bf..0a72417cfab 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -204,6 +204,9 @@ def is_type_attribute(name: str) -> bool: "external_reply", "via_bot", "is_from_offline", + "guest_bot_caller_chat", + "guest_bot_caller_user", + "guest_query_id", "show_caption_above_media", "paid_star_count", "is_paid_post", diff --git a/tests/test_sentguestmessage.py b/tests/test_sentguestmessage.py new file mode 100644 index 00000000000..0208342fb57 --- /dev/null +++ b/tests/test_sentguestmessage.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + +import pytest + +from telegram import SentGuestMessage +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def sent_guest_message(): + return SentGuestMessage(inline_message_id=SentGuestMessageTestBase.inline_message_id) + + +class SentGuestMessageTestBase: + inline_message_id = "123" + + +class TestSentGuestMessageWithoutRequest(SentGuestMessageTestBase): + def test_slot_behaviour(self, sent_guest_message): + inst = sent_guest_message + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_to_dict(self, sent_guest_message): + sent_guest_message_dict = sent_guest_message.to_dict() + + assert isinstance(sent_guest_message_dict, dict) + assert sent_guest_message_dict["inline_message_id"] == self.inline_message_id + + def test_de_json(self, offline_bot): + data = {"inline_message_id": self.inline_message_id} + m = SentGuestMessage.de_json(data, None) + assert m.api_kwargs == {} + assert m.inline_message_id == self.inline_message_id + + def test_equality(self): + a = SentGuestMessage(self.inline_message_id) + b = SentGuestMessage(self.inline_message_id) + c = SentGuestMessage("not_inline_message_id") + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a != c + assert hash(a) != hash(c)