diff --git a/README.rst b/README.rst index 9faa0caad..28edf11f8 100644 --- a/README.rst +++ b/README.rst @@ -14,7 +14,7 @@ :target: https://pypi.org/project/python-telegram-bot/ :alt: Supported Python versions -.. image:: https://img.shields.io/badge/Bot%20API-6.7-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-6.8-blue?logo=telegram :target: https://core.telegram.org/bots/api-changelog :alt: Supported Bot API versions @@ -93,7 +93,7 @@ Installing both ``python-telegram-bot`` and ``python-telegram-bot-raw`` in conju Telegram API support ==================== -All types and methods of the Telegram Bot API **6.7** are supported. +All types and methods of the Telegram Bot API **6.8** are supported. Installing ========== diff --git a/README_RAW.rst b/README_RAW.rst index 5152be47b..8c1831435 100644 --- a/README_RAW.rst +++ b/README_RAW.rst @@ -14,7 +14,7 @@ :target: https://pypi.org/project/python-telegram-bot-raw/ :alt: Supported Python versions -.. image:: https://img.shields.io/badge/Bot%20API-6.7-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-6.8-blue?logo=telegram :target: https://core.telegram.org/bots/api-changelog :alt: Supported Bot API versions @@ -89,7 +89,7 @@ Installing both ``python-telegram-bot`` and ``python-telegram-bot-raw`` in conju Telegram API support ==================== -All types and methods of the Telegram Bot API **6.7** are supported. +All types and methods of the Telegram Bot API **6.8** are supported. Installing ========== diff --git a/docs/source/inclusions/bot_methods.rst b/docs/source/inclusions/bot_methods.rst index 36b871c7b..dfe053fa1 100644 --- a/docs/source/inclusions/bot_methods.rst +++ b/docs/source/inclusions/bot_methods.rst @@ -328,6 +328,8 @@ - Used to reopen the general topic * - :meth:`~telegram.Bot.unpin_all_forum_topic_messages` - Used to unpin all messages in a forum topic + * - :meth:`~telegram.Bot.unpin_all_general_forum_topic_messages` + - Used to unpin all messages in the general forum topic .. raw:: html diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index 3dc01c6d7..a2e3de622 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -79,6 +79,7 @@ Available Types telegram.replykeyboardmarkup telegram.replykeyboardremove telegram.sentwebappmessage + telegram.story telegram.switchinlinequerychosenchat telegram.telegramobject telegram.update diff --git a/docs/source/telegram.story.rst b/docs/source/telegram.story.rst new file mode 100644 index 000000000..6b3b28d4a --- /dev/null +++ b/docs/source/telegram.story.rst @@ -0,0 +1,6 @@ +Story +===== + +.. autoclass:: telegram.Story + :members: + :show-inheritance: diff --git a/telegram/__init__.py b/telegram/__init__.py index fb3dcadd6..3a9faf064 100644 --- a/telegram/__init__.py +++ b/telegram/__init__.py @@ -170,6 +170,7 @@ __all__ = ( # Keep this alphabetically ordered "ShippingQuery", "Sticker", "StickerSet", + "Story", "SuccessfulPayment", "SwitchInlineQueryChosenChat", "TelegramObject", @@ -341,6 +342,7 @@ from ._replykeyboardmarkup import ReplyKeyboardMarkup from ._replykeyboardremove import ReplyKeyboardRemove from ._sentwebappmessage import SentWebAppMessage from ._shared import ChatShared, UserShared +from ._story import Story from ._switchinlinequerychosenchat import SwitchInlineQueryChosenChat from ._telegramobject import TelegramObject from ._update import Update diff --git a/telegram/_bot.py b/telegram/_bot.py index 1a72a05b0..2e835f50c 100644 --- a/telegram/_bot.py +++ b/telegram/_bot.py @@ -7814,6 +7814,46 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. api_kwargs=api_kwargs, ) + @_log + async def unpin_all_general_forum_topic_messages( + self, + chat_id: Union[str, int], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + ) -> bool: + """ + Use this method to clear the list of pinned messages in a General 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. + + .. versionadded:: NEXT.VERSION + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_group| + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = {"chat_id": chat_id} + + return await self._post( + "unpinAllGeneralForumTopicMessages", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + @_log async def edit_general_forum_topic( self, @@ -8527,3 +8567,5 @@ CUSTOM_EMOJI_IDENTIFIER_LIMIT` custom emoji identifiers can be specified. """Alias for :meth:`set_my_name`""" getMyName = get_my_name """Alias for :meth:`get_my_name`""" + unpinAllGeneralForumTopicMessages = unpin_all_general_forum_topic_messages + """Alias for :meth:`unpin_all_general_forum_topic_messages`""" diff --git a/telegram/_chat.py b/telegram/_chat.py index 26f0ecf58..3163ab06a 100644 --- a/telegram/_chat.py +++ b/telegram/_chat.py @@ -31,6 +31,7 @@ from telegram._menubutton import MenuButton from telegram._telegramobject import TelegramObject from telegram._utils import enum from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import ( CorrectOptionID, @@ -172,6 +173,12 @@ class Chat(TelegramObject): :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.0 + emoji_status_expiration_date (:class:`datetime.datetime`, optional): Expiration date of + emoji status of the other party in a private chat, in seconds. Returned only in + :meth:`telegram.Bot.get_chat`. + |datetime_localization| + + .. versionadded:: NEXT.VERSION has_aggressive_anti_spam_enabled (:obj:`bool`, optional): :obj:`True`, if aggressive anti-spam checks are enabled in the supergroup. The field is only available to chat administrators. Returned only in :meth:`telegram.Bot.get_chat`. @@ -265,6 +272,12 @@ class Chat(TelegramObject): :meth:`telegram.Bot.get_chat`. .. versionadded:: 20.0 + emoji_status_expiration_date (:class:`datetime.datetime`, optional): Expiration date of + emoji status of the other party in a private chat, in seconds. Returned only in + :meth:`telegram.Bot.get_chat`. + |datetime_localization| + + .. versionadded:: NEXT.VERSION has_aggressive_anti_spam_enabled (:obj:`bool`): Optional. :obj:`True`, if aggressive anti-spam checks are enabled in the supergroup. The field is only available to chat administrators. Returned only in :meth:`telegram.Bot.get_chat`. @@ -306,6 +319,7 @@ class Chat(TelegramObject): "is_forum", "active_usernames", "emoji_status_custom_emoji_id", + "emoji_status_expiration_date", "has_hidden_members", "has_aggressive_anti_spam_enabled", ) @@ -352,6 +366,7 @@ class Chat(TelegramObject): is_forum: Optional[bool] = None, active_usernames: Optional[Sequence[str]] = None, emoji_status_custom_emoji_id: Optional[str] = None, + emoji_status_expiration_date: Optional[datetime] = None, has_aggressive_anti_spam_enabled: Optional[bool] = None, has_hidden_members: Optional[bool] = None, *, @@ -390,6 +405,7 @@ class Chat(TelegramObject): self.is_forum: Optional[bool] = is_forum self.active_usernames: Tuple[str, ...] = parse_sequence_arg(active_usernames) self.emoji_status_custom_emoji_id: Optional[str] = emoji_status_custom_emoji_id + self.emoji_status_expiration_date: Optional[datetime] = emoji_status_expiration_date self.has_aggressive_anti_spam_enabled: Optional[bool] = has_aggressive_anti_spam_enabled self.has_hidden_members: Optional[bool] = has_hidden_members @@ -446,6 +462,13 @@ class Chat(TelegramObject): if not data: return None + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + data["emoji_status_expiration_date"] = from_timestamp( + data.get("emoji_status_expiration_date"), tzinfo=loc_tzinfo + ) + data["photo"] = ChatPhoto.de_json(data.get("photo"), bot) from telegram import Message # pylint: disable=import-outside-toplevel @@ -2904,6 +2927,37 @@ class Chat(TelegramObject): api_kwargs=api_kwargs, ) + async def unpin_all_general_forum_topic_messages( + self, + *, + 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.unpin_all_general_forum_topic_messages(chat_id=update.effective_chat.id, + *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.unpin_all_general_forum_topic_messages`. + + .. versionadded:: NEXT.VERSION + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().unpin_all_general_forum_topic_messages( + chat_id=self.id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def edit_general_forum_topic( self, name: str, diff --git a/telegram/_message.py b/telegram/_message.py index 6f6118adf..493afc3b5 100644 --- a/telegram/_message.py +++ b/telegram/_message.py @@ -53,6 +53,7 @@ from telegram._payment.successfulpayment import SuccessfulPayment from telegram._poll import Poll from telegram._proximityalerttriggered import ProximityAlertTriggered from telegram._shared import ChatShared, UserShared +from telegram._story import Story from telegram._telegramobject import TelegramObject from telegram._user import User from telegram._utils.argumentparsing import parse_sequence_arg @@ -201,6 +202,9 @@ class Message(TelegramObject): sticker (:class:`telegram.Sticker`, optional): Message is a sticker, information about the sticker. + story (:class:`telegram.Story`, optional): Message is a forwarded story. + + .. versionadded:: NEXT.VERSION video (:class:`telegram.Video`, optional): Message is a video, information about the video. voice (:class:`telegram.Voice`, optional): Message is a voice message, information about @@ -435,6 +439,9 @@ class Message(TelegramObject): about the sticker. .. seealso:: :wiki:`Working with Files and Media ` + story (:class:`telegram.Story`): Optional. Message is a forwarded story. + + .. versionadded:: NEXT.VERSION video (:class:`telegram.Video`): Optional. Message is a video, information about the video. @@ -671,6 +678,7 @@ class Message(TelegramObject): "has_media_spoiler", "user_shared", "chat_shared", + "story", ) def __init__( @@ -746,6 +754,7 @@ class Message(TelegramObject): has_media_spoiler: Optional[bool] = None, user_shared: Optional[UserShared] = None, chat_shared: Optional[ChatShared] = None, + story: Optional[Story] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -834,6 +843,7 @@ class Message(TelegramObject): self.has_media_spoiler: Optional[bool] = has_media_spoiler self.user_shared: Optional[UserShared] = user_shared self.chat_shared: Optional[ChatShared] = chat_shared + self.story: Optional[Story] = story self._effective_attachment = DEFAULT_NONE @@ -903,6 +913,7 @@ class Message(TelegramObject): data["game"] = Game.de_json(data.get("game"), bot) data["photo"] = PhotoSize.de_list(data.get("photo"), bot) data["sticker"] = Sticker.de_json(data.get("sticker"), bot) + data["story"] = Story.de_json(data.get("story"), bot) data["video"] = Video.de_json(data.get("video"), bot) data["voice"] = Voice.de_json(data.get("voice"), bot) data["video_note"] = VideoNote.de_json(data.get("video_note"), bot) @@ -973,6 +984,7 @@ class Message(TelegramObject): Sequence[PhotoSize], Poll, Sticker, + Story, SuccessfulPayment, Venue, Video, @@ -995,6 +1007,7 @@ class Message(TelegramObject): * List[:class:`telegram.PhotoSize`] * :class:`telegram.Poll` * :class:`telegram.Sticker` + * :class:`telegram.Story` * :class:`telegram.SuccessfulPayment` * :class:`telegram.Venue` * :class:`telegram.Video` diff --git a/telegram/_poll.py b/telegram/_poll.py index 3bbbaf103..c39d2b8fb 100644 --- a/telegram/_poll.py +++ b/telegram/_poll.py @@ -21,6 +21,7 @@ import datetime from typing import TYPE_CHECKING, Dict, Final, List, Optional, Sequence, Tuple from telegram import constants +from telegram._chat import Chat from telegram._messageentity import MessageEntity from telegram._telegramobject import TelegramObject from telegram._user import User @@ -28,6 +29,8 @@ from telegram._utils import enum from telegram._utils.argumentparsing import parse_sequence_arg from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.types import JSONDict +from telegram._utils.warnings import warn +from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: from telegram import Bot @@ -84,42 +87,86 @@ class PollAnswer(TelegramObject): Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their :attr:`poll_id`, :attr:`user` and :attr:`option_ids` are equal. + .. versionchanged:: NEXT.VERSION + The order of :paramref:`option_ids` and :paramref:`user` is changed in + NEXT.VERSION as the latter one became optional. We currently provide + backward compatibility for this but it will be removed in the future. + Please update your code to use the new order. + Args: poll_id (:obj:`str`): Unique poll identifier. - user (:class:`telegram.User`): The user, who changed the answer to the poll. - option_ids (Sequence[:obj:`int`]): 0-based identifiers of answer options, chosen by the - user. May be empty if the user retracted their vote. + option_ids (Sequence[:obj:`int`]): Identifiers of answer options, chosen by the user. May + be empty if the user retracted their vote. .. versionchanged:: 20.0 |sequenceclassargs| + user (:class:`telegram.User`, optional): The user that changed the answer to the poll, + if the voter isn't anonymous. If the voter is anonymous, this field will contain the + user :tg-const:`telegram.constants.ChatID.FAKE_CHANNEL` for backwards compatibility. + + .. versionchanged:: NEXT.VERSION + :paramref:`user` became optional. + voter_chat (:class:`telegram.Chat`, optional): The chat that changed the answer to the + poll, if the voter is anonymous. + + .. versionadded:: NEXT.VERSION Attributes: poll_id (:obj:`str`): Unique poll identifier. - user (:class:`telegram.User`): The user, who changed the answer to the poll. - option_ids (Tuple[:obj:`int`]): Identifiers of answer options, chosen by the user. May be - empty if the user retracted their vote. + option_ids (Tuple[:obj:`int`]): Identifiers of answer options, chosen by the user. May + be empty if the user retracted their vote. .. versionchanged:: 20.0 |tupleclassattrs| + user (:class:`telegram.User`): Optional. The user, who changed the answer to the + poll, if the voter isn't anonymous. If the voter is anonymous, this field will contain + the user :tg-const:`telegram.constants.ChatID.FAKE_CHANNEL` for backwards compatibility + + .. versionchanged:: NEXT.VERSION + :paramref:`user` became optional. + voter_chat (:class:`telegram.Chat`): Optional. The chat that changed the answer to the + poll, if the voter is anonymous. + + .. versionadded:: NEXT.VERSION """ - __slots__ = ("option_ids", "user", "poll_id") + __slots__ = ("option_ids", "poll_id", "user", "voter_chat") def __init__( self, poll_id: str, - user: User, option_ids: Sequence[int], + user: Optional[User] = None, + voter_chat: Optional[Chat] = None, *, api_kwargs: Optional[JSONDict] = None, ): super().__init__(api_kwargs=api_kwargs) self.poll_id: str = poll_id - self.user: User = user - self.option_ids: Tuple[int, ...] = parse_sequence_arg(option_ids) + self.voter_chat: Optional[Chat] = voter_chat - self._id_attrs = (self.poll_id, self.user, tuple(self.option_ids)) + if isinstance(option_ids, User) or isinstance(user, tuple): + warn( + "From v20.5 the order of `option_ids` and `user` is changed as the latter one" + " became optional. Please update your code to use the new order.", + category=PTBDeprecationWarning, + stacklevel=2, + ) + self.option_ids: Tuple[int, ...] = parse_sequence_arg(user) + self.user: Optional[User] = option_ids + else: + self.option_ids: Tuple[int, ...] = parse_sequence_arg( # type: ignore[no-redef] + option_ids + ) + self.user: Optional[User] = user # type: ignore[no-redef] + + self._id_attrs = ( + self.poll_id, + self.option_ids, + self.user, + self.voter_chat, + ) self._freeze() @@ -132,6 +179,7 @@ class PollAnswer(TelegramObject): return None data["user"] = User.de_json(data.get("user"), bot) + data["voter_chat"] = Chat.de_json(data.get("voter_chat"), bot) return super().de_json(data=data, bot=bot) diff --git a/telegram/_story.py b/telegram/_story.py new file mode 100644 index 000000000..e40d3b8ed --- /dev/null +++ b/telegram/_story.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2023 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object related to a Telegram Story.""" + +from typing import Optional + +from telegram._telegramobject import TelegramObject +from telegram._utils.types import JSONDict + + +class Story(TelegramObject): + """ + This object represents a message about a forwarded story in the chat. Currently holds no + information. + + .. versionadded:: NEXT.VERSION + + """ + + __slots__ = () + + def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + super().__init__(api_kwargs=api_kwargs) + + self._freeze() diff --git a/telegram/constants.py b/telegram/constants.py index f15aceacd..2266eb4b4 100644 --- a/telegram/constants.py +++ b/telegram/constants.py @@ -116,7 +116,7 @@ class _BotAPIVersion(NamedTuple): #: :data:`telegram.__bot_api_version_info__`. #: #: .. versionadded:: 20.0 -BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=6, minor=7) +BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=6, minor=8) #: :obj:`str`: Telegram Bot API #: version supported by this version of `python-telegram-bot`. Also available as #: :data:`telegram.__bot_api_version__`. @@ -285,7 +285,8 @@ class ChatID(IntEnum): __slots__ = () ANONYMOUS_ADMIN = 1087968824 - """:obj:`int`: User ID in groups for messages sent by anonymous admins. + """:obj:`int`: User ID in groups for messages sent by anonymous admins. Telegram chat: + `@GroupAnonymousBot `_. Note: :attr:`telegram.Message.from_user` will contain this ID for backwards compatibility only. @@ -293,19 +294,21 @@ class ChatID(IntEnum): """ SERVICE_CHAT = 777000 """:obj:`int`: Telegram service chat, that also acts as sender of channel posts forwarded to - discussion groups. + discussion groups. Telegram chat: `Telegram `_. Note: :attr:`telegram.Message.from_user` will contain this ID for backwards compatibility only. It's recommended to use :attr:`telegram.Message.sender_chat` instead. """ FAKE_CHANNEL = 136817688 - """:obj:`int`: User ID in groups when message is sent on behalf of a channel. + """:obj:`int`: User ID in groups when message is sent on behalf of a channel, or when a channel + votes on a poll. Telegram chat: `@Channel_Bot `_. Note: * :attr:`telegram.Message.from_user` will contain this ID for backwards compatibility only. It's recommended to use :attr:`telegram.Message.sender_chat` instead. - * This value is undocumented and might be changed by Telegram. + * :attr:`telegram.PollAnswer.user` will contain this ID for backwards compatibility only. + It's recommended to use :attr:`telegram.PollAnswer.voter_chat` instead. """ @@ -1065,6 +1068,8 @@ class MessageAttachmentType(StringEnum): """:obj:`str`: Messages with :attr:`telegram.Message.poll`.""" STICKER = "sticker" """:obj:`str`: Messages with :attr:`telegram.Message.sticker`.""" + STORY = "story" + """:obj:`str`: Messages with :attr:`telegram.Message.story`.""" SUCCESSFUL_PAYMENT = "successful_payment" """:obj:`str`: Messages with :attr:`telegram.Message.successful_payment`.""" VIDEO = "video" @@ -1219,6 +1224,8 @@ class MessageType(StringEnum): """:obj:`str`: Messages with :attr:`telegram.Message.poll`.""" STICKER = "sticker" """:obj:`str`: Messages with :attr:`telegram.Message.sticker`.""" + STORY = "story" + """:obj:`str`: Messages with :attr:`telegram.Message.story`.""" SUCCESSFUL_PAYMENT = "successful_payment" """:obj:`str`: Messages with :attr:`telegram.Message.successful_payment`.""" VIDEO = "video" diff --git a/telegram/ext/_extbot.py b/telegram/ext/_extbot.py index aa222c3d6..f60ced8e4 100644 --- a/telegram/ext/_extbot.py +++ b/telegram/ext/_extbot.py @@ -3486,6 +3486,26 @@ class ExtBot(Bot, Generic[RLARGS]): api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def unpin_all_general_forum_topic_messages( + self, + chat_id: Union[str, int], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = None, + ) -> bool: + return await super().unpin_all_general_forum_topic_messages( + chat_id=chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + async def upload_sticker_file( self, user_id: Union[str, int], @@ -3884,3 +3904,4 @@ class ExtBot(Bot, Generic[RLARGS]): setStickerMaskPosition = set_sticker_mask_position setMyName = set_my_name getMyName = get_my_name + unpinAllGeneralForumTopicMessages = unpin_all_general_forum_topic_messages diff --git a/telegram/ext/filters.py b/telegram/ext/filters.py index ae85caf5a..2a02e88ca 100644 --- a/telegram/ext/filters.py +++ b/telegram/ext/filters.py @@ -72,6 +72,7 @@ __all__ = ( "REPLY", "Regex", "Sticker", + "STORY", "SUCCESSFUL_PAYMENT", "SenderChat", "StatusUpdate", @@ -2143,6 +2144,20 @@ class Sticker: # neither mask nor emoji can be a message.sticker, so no filters for them +class _Story(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.story) + + +STORY = _Story(name="filters.STORY") +"""Messages that contain :attr:`telegram.Message.story`. + +.. versionadded:: NEXT.VERSION +""" + + class _SuccessfulPayment(MessageFilter): __slots__ = () diff --git a/tests/_utils/test_files.py b/tests/_utils/test_files.py index 9ec70c433..977567d85 100644 --- a/tests/_utils/test_files.py +++ b/tests/_utils/test_files.py @@ -33,8 +33,6 @@ class TestFiles: @pytest.mark.parametrize( ("string", "expected"), [ - (str(data_file("game.gif")), True), - (str(TEST_DATA_PATH), False), (str(data_file("game.gif")), True), (str(TEST_DATA_PATH), False), (data_file("game.gif"), True), diff --git a/tests/ext/test_filters.py b/tests/ext/test_filters.py index 80737f9ee..970df9e75 100644 --- a/tests/ext/test_filters.py +++ b/tests/ext/test_filters.py @@ -884,6 +884,11 @@ class TestFilters: assert filters.Sticker.VIDEO.check_update(update) assert filters.Sticker.PREMIUM.check_update(update) + def test_filters_story(self, update): + assert not filters.STORY.check_update(update) + update.message.story = "test" + assert filters.STORY.check_update(update) + def test_filters_video(self, update): assert not filters.VIDEO.check_update(update) update.message.video = "test" diff --git a/tests/ext/test_pollanswerhandler.py b/tests/ext/test_pollanswerhandler.py index 724e22f40..7b27868a7 100644 --- a/tests/ext/test_pollanswerhandler.py +++ b/tests/ext/test_pollanswerhandler.py @@ -69,7 +69,7 @@ def false_update(request): @pytest.fixture() def poll_answer(bot): - return Update(0, poll_answer=PollAnswer(1, User(2, "test user", False), [0, 1])) + return Update(0, poll_answer=PollAnswer(1, [0, 1], User(2, "test user", False), Chat(1, ""))) class TestPollAnswerHandler: diff --git a/tests/test_chat.py b/tests/test_chat.py index d93be7c9f..5d562a084 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -16,10 +16,12 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. +import time import pytest from telegram import Bot, Chat, ChatLocation, ChatPermissions, Location, User +from telegram._utils.datetime import UTC, from_timestamp from telegram.constants import ChatAction, ChatType from telegram.helpers import escape_markdown from tests.auxil.bot_method_checks import ( @@ -52,6 +54,7 @@ def chat(bot): is_forum=True, active_usernames=TestChatBase.active_usernames, emoji_status_custom_emoji_id=TestChatBase.emoji_status_custom_emoji_id, + emoji_status_expiration_date=TestChatBase.emoji_status_expiration_date, has_aggressive_anti_spam_enabled=TestChatBase.has_aggressive_anti_spam_enabled, has_hidden_members=TestChatBase.has_hidden_members, ) @@ -85,6 +88,7 @@ class TestChatBase: is_forum = True active_usernames = ["These", "Are", "Usernames!"] emoji_status_custom_emoji_id = "VeryUniqueCustomEmojiID" + emoji_status_expiration_date = time.time() has_aggressive_anti_spam_enabled = True has_hidden_members = True @@ -119,6 +123,7 @@ class TestChatWithoutRequest(TestChatBase): "is_forum": self.is_forum, "active_usernames": self.active_usernames, "emoji_status_custom_emoji_id": self.emoji_status_custom_emoji_id, + "emoji_status_expiration_date": self.emoji_status_expiration_date, "has_aggressive_anti_spam_enabled": self.has_aggressive_anti_spam_enabled, "has_hidden_members": self.has_hidden_members, } @@ -150,9 +155,32 @@ class TestChatWithoutRequest(TestChatBase): assert chat.is_forum == self.is_forum assert chat.active_usernames == tuple(self.active_usernames) assert chat.emoji_status_custom_emoji_id == self.emoji_status_custom_emoji_id + assert chat.emoji_status_expiration_date == from_timestamp( + self.emoji_status_expiration_date + ) assert chat.has_aggressive_anti_spam_enabled == self.has_aggressive_anti_spam_enabled assert chat.has_hidden_members == self.has_hidden_members + def test_de_json_localization(self, bot, raw_bot, tz_bot): + json_dict = { + "id": self.id_, + "type": self.type_, + "emoji_status_expiration_date": self.emoji_status_expiration_date, + } + chat_bot = Chat.de_json(json_dict, bot) + chat_bot_raw = Chat.de_json(json_dict, raw_bot) + chat_bot_tz = Chat.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing tzinfo objects is not reliable + emoji_expire_offset = chat_bot_tz.emoji_status_expiration_date.utcoffset() + emoji_expire_offset_tz = tz_bot.defaults.tzinfo.utcoffset( + chat_bot_tz.emoji_status_expiration_date.replace(tzinfo=None) + ) + + assert chat_bot.emoji_status_expiration_date.tzinfo == UTC + assert chat_bot_raw.emoji_status_expiration_date.tzinfo == UTC + assert emoji_expire_offset_tz == emoji_expire_offset + def test_to_dict(self, chat): chat_dict = chat.to_dict() @@ -177,6 +205,7 @@ class TestChatWithoutRequest(TestChatBase): assert chat_dict["is_forum"] == chat.is_forum assert chat_dict["active_usernames"] == list(chat.active_usernames) assert chat_dict["emoji_status_custom_emoji_id"] == chat.emoji_status_custom_emoji_id + assert chat_dict["emoji_status_expiration_date"] == chat.emoji_status_expiration_date assert ( chat_dict["has_aggressive_anti_spam_enabled"] == chat.has_aggressive_anti_spam_enabled ) @@ -1075,6 +1104,31 @@ class TestChatWithoutRequest(TestChatBase): monkeypatch.setattr(chat.get_bot(), "unpin_all_forum_topic_messages", make_assertion) assert await chat.unpin_all_forum_topic_messages(message_thread_id=42) + async def test_unpin_all_general_forum_topic_messages(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return kwargs["chat_id"] == chat.id + + assert check_shortcut_signature( + Chat.unpin_all_general_forum_topic_messages, + Bot.unpin_all_general_forum_topic_messages, + ["chat_id"], + [], + ) + assert await check_shortcut_call( + chat.unpin_all_general_forum_topic_messages, + chat.get_bot(), + "unpin_all_general_forum_topic_messages", + shortcut_kwargs=["chat_id"], + ) + assert await check_defaults_handling( + chat.unpin_all_general_forum_topic_messages, chat.get_bot() + ) + + monkeypatch.setattr( + chat.get_bot(), "unpin_all_general_forum_topic_messages", make_assertion + ) + assert await chat.unpin_all_general_forum_topic_messages() + async def test_edit_general_forum_topic(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == chat.id and kwargs["name"] == "WhatAName" diff --git a/tests/test_forum.py b/tests/test_forum.py index 961a7eb90..656ad8259 100644 --- a/tests/test_forum.py +++ b/tests/test_forum.py @@ -236,6 +236,7 @@ class TestForumMethodsWithRequest: assert result is True, "Failed to reopen forum topic" async def test_unpin_all_forum_topic_messages(self, bot, forum_group_id, real_topic): + # We need 2 or more pinned msgs for this to work, else we get Chat_not_modified error message_thread_id = real_topic.message_thread_id pin_msg_tasks = set() @@ -249,10 +250,23 @@ class TestForumMethodsWithRequest: assert all([await task for task in pin_msg_tasks]) is True, "Message(s) were not pinned" - # We need 2 or more pinned msgs for this to work, else we get Chat_not_modified error result = await bot.unpin_all_forum_topic_messages(forum_group_id, message_thread_id) assert result is True, "Failed to unpin all the messages in forum topic" + async def test_unpin_all_general_forum_topic_messages(self, bot, forum_group_id): + # We need 2 or more pinned msgs for this to work, else we get Chat_not_modified error + pin_msg_tasks = set() + + awaitables = {bot.send_message(forum_group_id, TEST_MSG_TEXT) for _ in range(2)} + for coro in asyncio.as_completed(awaitables): + msg = await coro + pin_msg_tasks.add(asyncio.create_task(msg.pin())) + + assert all([await task for task in pin_msg_tasks]) is True, "Message(s) were not pinned" + + result = await bot.unpin_all_general_forum_topic_messages(forum_group_id) + assert result is True, "Failed to unpin all the messages in forum topic" + async def test_edit_general_forum_topic(self, bot, forum_group_id): result = await bot.edit_general_forum_topic( chat_id=forum_group_id, diff --git a/tests/test_message.py b/tests/test_message.py index 86b3c0fa4..0bcdc3a35 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -42,6 +42,7 @@ from telegram import ( PollOption, ProximityAlertTriggered, Sticker, + Story, SuccessfulPayment, Update, User, @@ -123,6 +124,7 @@ def message(bot): }, {"photo": [PhotoSize("photo_id", "unique_id", 50, 50)], "caption": "photo_file"}, {"sticker": Sticker("sticker_id", "unique_id", 50, 50, True, False, Sticker.REGULAR)}, + {"story": Story()}, {"video": Video("video_id", "unique_id", 12, 12, 12), "caption": "video_file"}, {"voice": Voice("voice_id", "unique_id", 5)}, {"video_note": VideoNote("video_note_id", "unique_id", 20, 12)}, @@ -227,6 +229,7 @@ def message(bot): "game", "photo", "sticker", + "story", "video", "voice", "video_note", @@ -989,6 +992,7 @@ class TestMessageWithoutRequest(TestMessageBase): "photo", "poll", "sticker", + "story", "successful_payment", "video", "video_note", diff --git a/tests/test_poll.py b/tests/test_poll.py index d17f66736..ab6dfea61 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -19,9 +19,10 @@ from datetime import datetime, timedelta, timezone import pytest -from telegram import MessageEntity, Poll, PollAnswer, PollOption, User +from telegram import Chat, MessageEntity, Poll, PollAnswer, PollOption, User from telegram._utils.datetime import UTC, to_timestamp from telegram.constants import PollType +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -81,50 +82,58 @@ class TestPollOptionWithoutRequest(TestPollOptionBase): @pytest.fixture(scope="module") def poll_answer(): return PollAnswer( - TestPollAnswerBase.poll_id, TestPollAnswerBase.user, TestPollAnswerBase.poll_id + TestPollAnswerBase.poll_id, + TestPollAnswerBase.option_ids, + TestPollAnswerBase.user, + TestPollAnswerBase.voter_chat, ) class TestPollAnswerBase: poll_id = "id" - user = User(1, "", False) option_ids = [2] + user = User(1, "", False) + voter_chat = Chat(1, "") class TestPollAnswerWithoutRequest(TestPollAnswerBase): def test_de_json(self): json_dict = { "poll_id": self.poll_id, - "user": self.user.to_dict(), "option_ids": self.option_ids, + "user": self.user.to_dict(), + "voter_chat": self.voter_chat.to_dict(), } poll_answer = PollAnswer.de_json(json_dict, None) assert poll_answer.api_kwargs == {} assert poll_answer.poll_id == self.poll_id - assert poll_answer.user == self.user assert poll_answer.option_ids == tuple(self.option_ids) + assert poll_answer.user == self.user + assert poll_answer.voter_chat == self.voter_chat def test_to_dict(self, poll_answer): poll_answer_dict = poll_answer.to_dict() assert isinstance(poll_answer_dict, dict) assert poll_answer_dict["poll_id"] == poll_answer.poll_id - assert poll_answer_dict["user"] == poll_answer.user.to_dict() assert poll_answer_dict["option_ids"] == list(poll_answer.option_ids) + assert poll_answer_dict["user"] == poll_answer.user.to_dict() + assert poll_answer_dict["voter_chat"] == poll_answer.voter_chat.to_dict() def test_equality(self): - a = PollAnswer(123, self.user, [2]) - b = PollAnswer(123, User(1, "first", False), [2]) - c = PollAnswer(123, self.user, [1, 2]) - d = PollAnswer(456, self.user, [2]) - e = PollOption("Text", 1) + a = PollAnswer(123, [2], self.user, self.voter_chat) + b = PollAnswer(123, [2], self.user, Chat(1, "")) + c = PollAnswer(123, [2], User(1, "first", False), self.voter_chat) + d = PollAnswer(123, [1, 2], self.user, self.voter_chat) + e = PollAnswer(456, [2], self.user, self.voter_chat) + f = PollOption("Text", 1) assert a == b assert hash(a) == hash(b) - assert a != c - assert hash(a) != hash(c) + assert a == c + assert hash(a) == hash(c) assert a != d assert hash(a) != hash(d) @@ -132,6 +141,22 @@ class TestPollAnswerWithoutRequest(TestPollAnswerBase): assert a != e assert hash(a) != hash(e) + assert a != f + assert hash(a) != hash(f) + + def test_order_warning(self, recwarn): + expected_warning = ( + "From v20.5 the order of `option_ids` and `user` is changed as the latter one" + " became optional. Please update your code to use the new order." + ) + PollAnswer(123, [2], self.user, self.voter_chat) + assert len(recwarn) == 0 + PollAnswer(123, self.user, [2], self.voter_chat) + assert len(recwarn) == 1 + assert str(recwarn[0].message) == expected_warning + assert recwarn[0].category is PTBDeprecationWarning + assert recwarn[0].filename == __file__, "wrong stacklevel" + @pytest.fixture(scope="module") def poll(): diff --git a/tests/test_story.py b/tests/test_story.py new file mode 100644 index 000000000..2aa9358ad --- /dev/null +++ b/tests/test_story.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2023 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + +import pytest + +from telegram import Story +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def story(): + return Story() + + +class TestStoryWithoutRequest: + def test_slot_behaviour(self): + story = Story() + for attr in story.__slots__: + assert getattr(story, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(story)) == len(set(mro_slots(story))), "duplicate slot" + + def test_de_json(self): + story = Story.de_json({}, None) + assert story.api_kwargs == {} + assert isinstance(story, Story) + + def test_to_dict(self): + story = Story() + story_dict = story.to_dict() + assert story_dict == {} diff --git a/tests/test_update.py b/tests/test_update.py index 3e5ed3b6f..7068d4cb5 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -70,7 +70,18 @@ params = [ {"shipping_query": ShippingQuery("id", User(1, "", False), "", None)}, {"pre_checkout_query": PreCheckoutQuery("id", User(1, "", False), "", 0, "")}, {"poll": Poll("id", "?", [PollOption(".", 1)], False, False, False, Poll.REGULAR, True)}, - {"poll_answer": PollAnswer("id", User(1, "", False), [1])}, + { + "poll_answer": PollAnswer( + "id", + [1], + User( + 1, + "", + False, + ), + Chat(1, ""), + ) + }, {"my_chat_member": chat_member_updated}, {"chat_member": chat_member_updated}, {"chat_join_request": chat_join_request},