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)