diff --git a/changes/unreleased/5232.eie6iBtaicy9eTpwYTVPAs.toml b/changes/unreleased/5232.eie6iBtaicy9eTpwYTVPAs.toml new file mode 100644 index 00000000000..2e328922243 --- /dev/null +++ b/changes/unreleased/5232.eie6iBtaicy9eTpwYTVPAs.toml @@ -0,0 +1,5 @@ +features = "Bot API 10.0 Polls" +[[pull_requests]] +uid = "5232" +author_uids = ["aelkheir"] +closes_threads = [] diff --git a/docs/auxil/sphinx_hooks.py b/docs/auxil/sphinx_hooks.py index 7183a0dbaf7..796c367e2b0 100644 --- a/docs/auxil/sphinx_hooks.py +++ b/docs/auxil/sphinx_hooks.py @@ -52,6 +52,7 @@ "_BaseMedium": "TelegramObject", "_CredentialsBase": "TelegramObject", "_ChatBase": "TelegramObject", + "_BaseInputMedia": "TelegramObject", } diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index 25d9c269838..9681a3f7224 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -101,15 +101,20 @@ Available Types telegram.inputmediaanimation telegram.inputmediaaudio telegram.inputmediadocument + telegram.inputmedialocation telegram.inputmediaphoto + telegram.inputmediasticker + telegram.inputmediavenue telegram.inputmediavideo telegram.inputpaidmedia telegram.inputpaidmediaphoto telegram.inputpaidmediavideo + telegram.inputpollmedia telegram.inputprofilephoto telegram.inputprofilephotoanimated telegram.inputprofilephotostatic telegram.inputpolloption + telegram.inputpolloptionmedia telegram.inputstorycontent telegram.inputstorycontentphoto telegram.inputstorycontentvideo @@ -154,6 +159,8 @@ Available Types telegram.photosize telegram.poll telegram.pollanswer + telegram.pollmedia + telegram.polloption telegram.polloptionadded telegram.polloptiondeleted telegram.preparedkeyboardbutton diff --git a/docs/source/telegram.inputmedialocation.rst b/docs/source/telegram.inputmedialocation.rst new file mode 100644 index 00000000000..aa20d631ea4 --- /dev/null +++ b/docs/source/telegram.inputmedialocation.rst @@ -0,0 +1,6 @@ +InputMediaLocation +================== + +.. autoclass:: telegram.InputMediaLocation + :members: + :show-inheritance: diff --git a/docs/source/telegram.inputmediasticker.rst b/docs/source/telegram.inputmediasticker.rst new file mode 100644 index 00000000000..7f2b6d7778e --- /dev/null +++ b/docs/source/telegram.inputmediasticker.rst @@ -0,0 +1,6 @@ +InputMediaSticker +================= + +.. autoclass:: telegram.InputMediaSticker + :members: + :show-inheritance: diff --git a/docs/source/telegram.inputmediavenue.rst b/docs/source/telegram.inputmediavenue.rst new file mode 100644 index 00000000000..e5e221e2f73 --- /dev/null +++ b/docs/source/telegram.inputmediavenue.rst @@ -0,0 +1,6 @@ +InputMediaVenue +=============== + +.. autoclass:: telegram.InputMediaVenue + :members: + :show-inheritance: diff --git a/docs/source/telegram.inputpollmedia.rst b/docs/source/telegram.inputpollmedia.rst new file mode 100644 index 00000000000..d7cd0d045ee --- /dev/null +++ b/docs/source/telegram.inputpollmedia.rst @@ -0,0 +1,6 @@ +InputPollMedia +============== + +.. autoclass:: telegram.InputPollMedia + :members: + :show-inheritance: diff --git a/docs/source/telegram.inputpolloptionmedia.rst b/docs/source/telegram.inputpolloptionmedia.rst new file mode 100644 index 00000000000..22207ce305c --- /dev/null +++ b/docs/source/telegram.inputpolloptionmedia.rst @@ -0,0 +1,6 @@ +InputPollOptionMedia +==================== + +.. autoclass:: telegram.InputPollOptionMedia + :members: + :show-inheritance: diff --git a/docs/source/telegram.pollmedia.rst b/docs/source/telegram.pollmedia.rst new file mode 100644 index 00000000000..8e7b38871b4 --- /dev/null +++ b/docs/source/telegram.pollmedia.rst @@ -0,0 +1,6 @@ +PollMedia +========= + +.. autoclass:: telegram.PollMedia + :members: + :show-inheritance: diff --git a/src/telegram/__init__.py b/src/telegram/__init__.py index 362f3253198..9d149485daa 100644 --- a/src/telegram/__init__.py +++ b/src/telegram/__init__.py @@ -157,13 +157,18 @@ "InputMediaAnimation", "InputMediaAudio", "InputMediaDocument", + "InputMediaLocation", "InputMediaPhoto", + "InputMediaSticker", + "InputMediaVenue", "InputMediaVideo", "InputMessageContent", "InputPaidMedia", "InputPaidMediaPhoto", "InputPaidMediaVideo", + "InputPollMedia", "InputPollOption", + "InputPollOptionMedia", "InputProfilePhoto", "InputProfilePhotoAnimated", "InputProfilePhotoStatic", @@ -231,6 +236,7 @@ "PhotoSize", "Poll", "PollAnswer", + "PollMedia", "PollOption", "PollOptionAdded", "PollOptionDeleted", @@ -435,11 +441,16 @@ InputMediaAnimation, InputMediaAudio, InputMediaDocument, + InputMediaLocation, InputMediaPhoto, + InputMediaSticker, + InputMediaVenue, InputMediaVideo, InputPaidMedia, InputPaidMediaPhoto, InputPaidMediaVideo, + InputPollMedia, + InputPollOptionMedia, ) from ._files.inputprofilephoto import ( InputProfilePhoto, @@ -579,6 +590,7 @@ InputPollOption, Poll, PollAnswer, + PollMedia, PollOption, PollOptionAdded, PollOptionDeleted, diff --git a/src/telegram/_bot.py b/src/telegram/_bot.py index d9d1f83b069..94ce357b7b7 100644 --- a/src/telegram/_bot.py +++ b/src/telegram/_bot.py @@ -128,6 +128,7 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputPollMedia, InputProfilePhoto, InputSticker, InputStoryContent, @@ -7627,6 +7628,10 @@ async def send_poll( description_parse_mode: str | None = None, description_entities: Sequence["MessageEntity"] | None = None, shuffle_options: bool | None = None, + members_only: bool | None = None, + country_codes: Sequence[str] | None = None, + explanation_media: "InputPollMedia | None" = None, + media: "InputPollMedia | None" = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, reply_to_message_id: int | None = None, @@ -7769,6 +7774,27 @@ async def send_poll( shuffle_options (:obj:`bool`, optional): :obj:`True`, if the poll options must be shown in random order + .. versionadded:: NEXT.VERSION + members_only (:obj:`bool`, optional): :obj:`True`, if voting is limited to users who + have been members of the chat where the poll is being sent for more than + :tg-const:`telegram.Poll.MIN_MEMBERSHIP_HOURS` hours; for channel chats only + + .. versionadded:: NEXT.VERSION + country_codes (Sequence[:obj:`str`], optional): A list of + 0-:tg-const:`telegram.constants.PollLimit.MAX_COUNTRY_CODES` two-letter + ``ISO 3166-1 alpha-2`` country codes indicating the countries from which users can + vote in the poll; for channel chats only. Use ``“FT”`` as a country code to allow + users with anonymous numbers to vote. If omitted or empty, then users from any + country can participate in the poll. + + .. versionadded:: NEXT.VERSION + explanation_media (:class:`telegram.InputPollMedia`, optional): Media added to the quiz + explanation + + .. versionadded:: NEXT.VERSION + media (:class:`telegram.InputPollMedia`, optional): Media added to the poll + description. + .. versionadded:: NEXT.VERSION Keyword Args: @@ -7837,6 +7863,10 @@ async def send_poll( "close_date": close_date, "question_parse_mode": question_parse_mode, "question_entities": question_entities, + "members_only": members_only, + "country_codes": country_codes, + "explanation_media": explanation_media, + "media": media, } return await self._send_message( diff --git a/src/telegram/_chat.py b/src/telegram/_chat.py index 764da868e7e..4d62c987ed3 100644 --- a/src/telegram/_chat.py +++ b/src/telegram/_chat.py @@ -61,6 +61,7 @@ InputMediaPhoto, InputMediaVideo, InputPaidMedia, + InputPollMedia, InputPollOption, LabeledPrice, LinkPreviewOptions, @@ -2305,6 +2306,10 @@ async def send_poll( description: str | None = None, description_parse_mode: str | None = None, description_entities: Sequence["MessageEntity"] | None = None, + members_only: bool | None = None, + country_codes: Sequence[str] | None = None, + explanation_media: "InputPollMedia | None" = None, + media: "InputPollMedia | None" = None, *, reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, @@ -2363,6 +2368,10 @@ async def send_poll( description_entities=description_entities, hide_results_until_closes=hide_results_until_closes, allow_adding_options=allow_adding_options, + members_only=members_only, + country_codes=country_codes, + explanation_media=explanation_media, + media=media, ) async def send_copy( diff --git a/src/telegram/_files/inputmedia.py b/src/telegram/_files/inputmedia.py index 23b6620985a..951a7e681ca 100644 --- a/src/telegram/_files/inputmedia.py +++ b/src/telegram/_files/inputmedia.py @@ -28,6 +28,7 @@ from telegram._files.document import Document from telegram._files.inputfile import InputFile from telegram._files.photosize import PhotoSize +from telegram._files.sticker import Sticker from telegram._files.video import Video from telegram._messageentity import MessageEntity from telegram._telegramobject import TelegramObject @@ -37,7 +38,7 @@ from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.files import parse_file_input from telegram._utils.types import JSONDict, ODVInput, TimePeriod -from telegram.constants import InputMediaType +from telegram.constants import BaseInputMediaType if TYPE_CHECKING: from telegram._utils.types import FileInput @@ -45,7 +46,97 @@ MediaType: TypeAlias = Animation | Audio | Document | PhotoSize | Video -class InputMedia(TelegramObject): +class _BaseInputMedia(TelegramObject): + """ + Base class for objects representing the various input media types. + + Args: + media_type (:obj:`str`): Type of media that the instance represents. + + Attributes: + type (:obj:`str`): Type of media that the instance represents. + """ + + __slots__ = ("type",) + + def __init__( + self, + media_type: str, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.type: str = enum.get_member(constants.BaseInputMediaType, media_type, media_type) + + self._freeze() + + +class InputPollMedia(_BaseInputMedia): + """Base class for Telegram InputPollMedia Objects. Currently, it can be one of: + + * :class:`telegram.InputMediaAnimation` + * :class:`telegram.InputMediaAudio` + * :class:`telegram.InputMediaDocument` + * :class:`telegram.InputMediaLocation` + * :class:`telegram.InputMediaPhoto` + * :class:`telegram.InputMediaVenue` + * :class:`telegram.InputMediaVideo` + + .. TODO: LivePhoto + + .. versionadded:: NEXT.VERSION + + Args: + media_type (:obj:`str`): Type of the input poll media. + + Attributes: + type (:obj:`str`): Type of the input poll media. + """ + + __slots__ = () + + def __init__( + self, + media_type: str, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(media_type=media_type, api_kwargs=api_kwargs) + + +class InputPollOptionMedia(_BaseInputMedia): + """Base class for Telegram InputPollOptionMedia Objects. Currently, it can be one of: + + * :class:`telegram.InputMediaAnimation` + * :class:`telegram.InputMediaLocation` + * :class:`telegram.InputMediaPhoto` + * :class:`telegram.InputMediaSticker` + * :class:`telegram.InputMediaVenue` + * :class:`telegram.InputMediaVideo` + + .. TODO: LivePhoto + + .. versionadded:: NEXT.VERSION + + Args: + media_type (:obj:`str`): Type of the input poll option media. + + Attributes: + type (:obj:`str`): Type of the input poll option media. + """ + + __slots__ = () + + def __init__( + self, + media_type: str, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(media_type=media_type, api_kwargs=api_kwargs) + + +class InputMedia(_BaseInputMedia): """ Base class for Telegram InputMedia Objects. @@ -85,7 +176,7 @@ class InputMedia(TelegramObject): """ - __slots__ = ("caption", "caption_entities", "media", "parse_mode", "type") + __slots__ = ("caption", "caption_entities", "media", "parse_mode") def __init__( self, @@ -97,14 +188,12 @@ def __init__( *, api_kwargs: JSONDict | None = None, ): - super().__init__(api_kwargs=api_kwargs) - self.type: str = enum.get_member(constants.InputMediaType, media_type, media_type) - self.media: str | InputFile = media - self.caption: str | None = caption - self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.parse_mode: ODVInput[str] = parse_mode - - self._freeze() + super().__init__(media_type=media_type, api_kwargs=api_kwargs) + with self._unfrozen(): + self.media: str | InputFile = media + self.caption: str | None = caption + self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) + self.parse_mode: ODVInput[str] = parse_mode @staticmethod def _parse_thumbnail_input(thumbnail: "FileInput | None") -> str | InputFile | None: @@ -300,7 +389,7 @@ def duration(self) -> int | dtm.timedelta | None: return get_timedelta_value(self._duration, attribute="duration") -class InputMediaAnimation(InputMedia): +class InputMediaAnimation(InputMedia, InputPollMedia, InputPollOptionMedia): """Represents an animation file (GIF or H.264/MPEG-4 AVC video without sound) to be sent. Note: @@ -354,7 +443,7 @@ class InputMediaAnimation(InputMedia): .. versionadded:: 21.3 Attributes: - type (:obj:`str`): :tg-const:`telegram.constants.InputMediaType.ANIMATION`. + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.ANIMATION`. media (:obj:`str` | :class:`telegram.InputFile`): Animation to send. caption (:obj:`str`): Optional. Caption of the animation to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters @@ -421,7 +510,7 @@ def __init__( media = parse_file_input(media, filename=filename, attach=True, local_mode=True) super().__init__( - InputMediaType.ANIMATION, + BaseInputMediaType.ANIMATION, media, caption, caption_entities, @@ -441,7 +530,7 @@ def duration(self) -> int | dtm.timedelta | None: return get_timedelta_value(self._duration, attribute="duration") -class InputMediaPhoto(InputMedia): +class InputMediaPhoto(InputMedia, InputPollMedia, InputPollOptionMedia): """Represents a photo to be sent. .. seealso:: :wiki:`Working with Files and Media ` @@ -475,7 +564,7 @@ class InputMediaPhoto(InputMedia): .. versionadded:: 21.3 Attributes: - type (:obj:`str`): :tg-const:`telegram.constants.InputMediaType.PHOTO`. + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.PHOTO`. media (:obj:`str` | :class:`telegram.InputFile`): Photo to send. caption (:obj:`str`): Optional. Caption of the photo to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters @@ -517,7 +606,7 @@ def __init__( # things to work in local mode. media = parse_file_input(media, PhotoSize, filename=filename, attach=True, local_mode=True) super().__init__( - InputMediaType.PHOTO, + BaseInputMediaType.PHOTO, media, caption, caption_entities, @@ -530,7 +619,7 @@ def __init__( self.show_caption_above_media: bool | None = show_caption_above_media -class InputMediaVideo(InputMedia): +class InputMediaVideo(InputMedia, InputPollMedia, InputPollOptionMedia): """Represents a video to be sent. .. seealso:: :wiki:`Working with Files and Media ` @@ -595,7 +684,7 @@ class InputMediaVideo(InputMedia): .. versionadded:: 21.3 Attributes: - type (:obj:`str`): :tg-const:`telegram.constants.InputMediaType.VIDEO`. + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.VIDEO`. media (:obj:`str` | :class:`telegram.InputFile`): Video file to send. caption (:obj:`str`): Optional. Caption of the video to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters @@ -676,7 +765,7 @@ def __init__( media = parse_file_input(media, filename=filename, attach=True, local_mode=True) super().__init__( - InputMediaType.VIDEO, + BaseInputMediaType.VIDEO, media, caption, caption_entities, @@ -701,7 +790,157 @@ def duration(self) -> int | dtm.timedelta | None: return get_timedelta_value(self._duration, attribute="duration") -class InputMediaAudio(InputMedia): +class InputMediaLocation(InputPollMedia, InputPollOptionMedia): + """Represents a location to be sent. + + .. versionadded:: NEXT.VERSION + + Args: + latitude (:obj:`float`): Latitude of the location. + longitude (:obj:`float`): Longitude of the location. + horizontal_accuracy (:obj:`float`, optional): The radius of uncertainty for the location, + measured in meters; 0-:tg-const:`telegram.Location.HORIZONTAL_ACCURACY`. + + Attributes: + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.LOCATION`. + latitude (:obj:`float`): Latitude of the location. + longitude (:obj:`float`): Longitude of the location. + horizontal_accuracy (:obj:`float`): Optional. The radius of uncertainty for the location, + measured in meters; 0-:tg-const:`telegram.Location.HORIZONTAL_ACCURACY`. + """ + + __slots__ = ("horizontal_accuracy", "latitude", "longitude") + + def __init__( + self, + latitude: float, + longitude: float, + horizontal_accuracy: float | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(media_type=BaseInputMediaType.LOCATION, api_kwargs=api_kwargs) + with self._unfrozen(): + self.latitude: float = latitude + self.longitude: float = longitude + self.horizontal_accuracy: float | None = horizontal_accuracy + + +class InputMediaVenue(InputPollMedia, InputPollOptionMedia): + """Represents a venue to be sent. + + .. versionadded:: NEXT.VERSION + + Args: + latitude (:obj:`float`): Latitude of the location. + longitude (:obj:`float`): Longitude of the location. + title (:obj:`str`): Name of the venue. + address (:obj:`str`): Address of the venue. + foursquare_id (:obj:`str`, optional): Foursquare identifier of the venue. + foursquare_type (:obj:`str`, optional): Foursquare type of the venue, if known. (For + example, ``“arts_entertainment/default”``, ``“arts_entertainment/aquarium”`` + or ``“food/icecream”``). + google_place_id (:obj:`str`, optional): Google Places identifier of the venue. + google_place_type (:obj:`str`, optional): Google Places type of the venue. (See\ + `supported types `__) + + Attributes: + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.VENUE`. + latitude (:obj:`float`): Latitude of the location. + longitude (:obj:`float`): Longitude of the location. + title (:obj:`str`): Name of the venue. + address (:obj:`str`): Address of the venue. + foursquare_id (:obj:`str`): Optional. Foursquare identifier of the venue. + foursquare_type (:obj:`str`): Optional. Foursquare type of the venue, if known. (For + example, ``“arts_entertainment/default”``, ``“arts_entertainment/aquarium”`` + or ``“food/icecream”``). + google_place_id (:obj:`str`): Optional. Google Places identifier of the venue. + google_place_type (:obj:`str`): Optional. Google Places type of the venue. (See\ + `supported types `__) + """ + + __slots__ = ( + "address", + "foursquare_id", + "foursquare_type", + "google_place_id", + "google_place_type", + "latitude", + "longitude", + "title", + ) + + def __init__( + self, + latitude: float, + longitude: float, + title: str, + address: str, + foursquare_id: str | None = None, + foursquare_type: str | None = None, + google_place_id: str | None = None, + google_place_type: str | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(media_type=BaseInputMediaType.VENUE, api_kwargs=api_kwargs) + with self._unfrozen(): + self.latitude: float = latitude + self.longitude: float = longitude + self.title: str = title + self.address: str = address + self.foursquare_id: str | None = foursquare_id + self.foursquare_type: str | None = foursquare_type + self.google_place_id: str | None = google_place_id + self.google_place_type: str | None = google_place_type + + +class InputMediaSticker(InputPollOptionMedia): + """Represents a sticker file to be sent. + + .. seealso:: :wiki:`Working with Files and Media ` + + .. versionadded:: NEXT.VERSION + + Args: + media (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` | \ + :class:`pathlib.Path` | :class:`telegram.Sticker`): File to send. |fileinputnopath| + + Lastly you can pass an existing :class:`telegram.Sticker` object to send. + emoji (:obj:`str`, optional): Emoji associated with the sticker; only for just uploaded + stickers. + filename (:obj:`str`, optional): Custom file name for the sticker, when uploading a + new file. + + Attributes: + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.STICKER`. + media (:obj:`str` | :class:`telegram.InputFile`): Sticker file to send. + emoji (:obj:`str`): Optional. Emoji associated with the sticker; only for just uploaded + stickers. + """ + + __slots__ = ("emoji", "media") + + def __init__( + self, + media: "FileInput | Sticker", + emoji: str | None = None, + filename: str | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + if isinstance(media, Sticker): + media = media.file_id + else: + media = parse_file_input(media, filename=filename, attach=True, local_mode=True) + + super().__init__(media_type=BaseInputMediaType.STICKER, api_kwargs=api_kwargs) + with self._unfrozen(): + self.media: str | InputFile = media + self.emoji: str | None = emoji + + +class InputMediaAudio(InputMedia, InputPollMedia): """Represents an audio file to be treated as music to be sent. .. seealso:: :wiki:`Working with Files and Media ` @@ -749,7 +988,7 @@ class InputMediaAudio(InputMedia): .. versionadded:: 20.2 Attributes: - type (:obj:`str`): :tg-const:`telegram.constants.InputMediaType.AUDIO`. + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.AUDIO`. media (:obj:`str` | :class:`telegram.InputFile`): Audio file to send. caption (:obj:`str`): Optional. Caption of the audio to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters @@ -802,7 +1041,7 @@ def __init__( media = parse_file_input(media, filename=filename, attach=True, local_mode=True) super().__init__( - InputMediaType.AUDIO, + BaseInputMediaType.AUDIO, media, caption, caption_entities, @@ -820,7 +1059,7 @@ def duration(self) -> int | dtm.timedelta | None: return get_timedelta_value(self._duration, attribute="duration") -class InputMediaDocument(InputMedia): +class InputMediaDocument(InputMedia, InputPollMedia): """Represents a general file to be sent. .. seealso:: :wiki:`Working with Files and Media ` @@ -858,7 +1097,7 @@ class InputMediaDocument(InputMedia): .. versionadded:: 20.2 Attributes: - type (:obj:`str`): :tg-const:`telegram.constants.InputMediaType.DOCUMENT`. + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.DOCUMENT`. media (:obj:`str` | :class:`telegram.InputFile`): File to send. caption (:obj:`str`): Optional. Caption of the document to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters @@ -897,7 +1136,7 @@ def __init__( media = parse_file_input(media, Document, filename=filename, attach=True, local_mode=True) super().__init__( - InputMediaType.DOCUMENT, + BaseInputMediaType.DOCUMENT, media, caption, caption_entities, diff --git a/src/telegram/_message.py b/src/telegram/_message.py index 8c6626f50a6..ea3a6847364 100644 --- a/src/telegram/_message.py +++ b/src/telegram/_message.py @@ -115,6 +115,7 @@ InputMediaPhoto, InputMediaVideo, InputPaidMedia, + InputPollMedia, InputPollOption, LabeledPrice, MessageId, @@ -3537,6 +3538,10 @@ async def reply_poll( description: str | None = None, description_parse_mode: str | None = None, description_entities: Sequence["MessageEntity"] | None = None, + members_only: bool | None = None, + country_codes: Sequence[str] | None = None, + explanation_media: "InputPollMedia | None" = None, + media: "InputPollMedia | None" = None, *, reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, @@ -3615,6 +3620,10 @@ async def reply_poll( description_entities=description_entities, hide_results_until_closes=hide_results_until_closes, allow_adding_options=allow_adding_options, + members_only=members_only, + country_codes=country_codes, + explanation_media=explanation_media, + media=media, ) async def reply_dice( diff --git a/src/telegram/_poll.py b/src/telegram/_poll.py index ba02062b33a..8fd288d53f1 100644 --- a/src/telegram/_poll.py +++ b/src/telegram/_poll.py @@ -24,6 +24,14 @@ from telegram import constants from telegram._chat import Chat +from telegram._files.animation import Animation +from telegram._files.audio import Audio +from telegram._files.document import Document +from telegram._files.location import Location +from telegram._files.photosize import PhotoSize +from telegram._files.sticker import Sticker +from telegram._files.venue import Venue +from telegram._files.video import Video from telegram._messageentity import MessageEntity from telegram._telegramobject import TelegramObject from telegram._user import User @@ -46,7 +54,120 @@ from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: - from telegram import Bot, MaybeInaccessibleMessage + from telegram import Bot, InputPollOptionMedia, MaybeInaccessibleMessage + + +class PollMedia(TelegramObject): + """ + At most one of the optional fields can be present in any given object. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if all of their attributes are equal. + + .. versionadded:: NEXT.VERSION + + Args: + animation (:class:`telegram.Animation`, optional): Media is an animation, information about + the animation + audio (:class:`telegram.Audio`, optional): Media is an audio file, information about the + file; currently, can't be received in a poll option + document (:class:`telegram.Document`, optional): Media is a general file, information about + the file; currently, can't be received in a poll option + .. TODO: LivePhoto + location (:class:`telegram.Location`, optional): Media is a shared location, information + about the location + photo (Sequence[:class:`telegram.PhotoSize`], optional): Media is a photo, available sizes + of the photo + sticker (:class:`telegram.Sticker`, optional): Media is a sticker, information about the + sticker; currently, for poll options only + venue (:class:`telegram.Venue`, optional): Media is a venue, information about the venue + video (:class:`telegram.Video`, optional): Media is a video, information about the video + + Attributes: + animation (:class:`telegram.Animation`): Optional. Media is an animation, information about + the animation + audio (:class:`telegram.Audio`): Optional. Media is an audio file, information about the + file; currently, can't be received in a poll option + document (:class:`telegram.Document`): Optional. Media is a general file, information about + the file; currently, can't be received in a poll option + .. TODO: LivePhoto + location (:class:`telegram.Location`): Optional. Media is a shared location, information + about the location + photo (Sequence[:class:`telegram.PhotoSize`]): Optional. Media is a photo, available sizes + of the photo + sticker (:class:`telegram.Sticker`): Optional. Media is a sticker, information about the + sticker; currently, for poll options only + venue (:class:`telegram.Venue`): Optional. Media is a venue, information about the venue + video (:class:`telegram.Video`): Optional. Media is a video, information about the video + """ + + __slots__ = ( + "animation", + "audio", + "document", + "location", + "photo", + "sticker", + "venue", + "video", + # TODO: LivePhoto + ) + + def __init__( + self, + animation: Animation | None = None, + audio: Audio | None = None, + document: Document | None = None, + location: Location | None = None, + photo: Sequence[PhotoSize] | None = None, + sticker: Sticker | None = None, + venue: Venue | None = None, + video: Video | None = None, + # TODO: LivePhoto + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.animation: Animation | None = animation + self.audio: Audio | None = audio + self.document: Document | None = document + self.location: Location | None = location + self.photo: tuple[PhotoSize, ...] = parse_sequence_arg(photo) + self.sticker: Sticker | None = sticker + self.venue: Venue | None = venue + self.video: Video | None = video + # TODO: LivePhoto + + self._id_attrs = ( + self.animation, + self.audio, + self.document, + self.location, + self.photo, + self.sticker, + self.venue, + self.video, + # TODO: LivePhoto + ) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PollMedia": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["animation"] = de_json_optional(data.get("animation"), Animation, bot) + data["audio"] = de_json_optional(data.get("audio"), Audio, bot) + data["document"] = de_json_optional(data.get("document"), Document, bot) + data["location"] = de_json_optional(data.get("location"), Location, bot) + data["photo"] = de_list_optional(data.get("photo"), PhotoSize, bot) + data["sticker"] = de_json_optional(data.get("sticker"), Sticker, bot) + data["venue"] = de_json_optional(data.get("venue"), Venue, bot) + data["video"] = de_json_optional(data.get("video"), Video, bot) + # TODO: LivePhoto + + return super().de_json(data=data, bot=bot) class InputPollOption(TelegramObject): @@ -69,6 +190,9 @@ class InputPollOption(TelegramObject): :paramref:`text_parse_mode`. Currently, only custom emoji entities are allowed. This list is empty if the text does not contain entities. + media (:class:`telegram.InputPollOptionMedia`, optional): Media added to the poll option. + + .. versionadded:: NEXT.VERSION Attributes: text (:obj:`str`): Option text, @@ -81,15 +205,19 @@ class InputPollOption(TelegramObject): :paramref:`text_parse_mode`. Currently, only custom emoji entities are allowed. This list is empty if the text does not contain entities. + media (:class:`telegram.InputPollOptionMedia`): Optional. Media added to the poll option. + + .. versionadded:: NEXT.VERSION """ - __slots__ = ("text", "text_entities", "text_parse_mode") + __slots__ = ("media", "text", "text_entities", "text_parse_mode") def __init__( self, text: str, text_parse_mode: ODVInput[str] = DEFAULT_NONE, text_entities: Sequence[MessageEntity] | None = None, + media: "InputPollOptionMedia | None" = None, *, api_kwargs: JSONDict | None = None, ): @@ -97,14 +225,30 @@ def __init__( self.text: str = text self.text_parse_mode: ODVInput[str] = text_parse_mode self.text_entities: tuple[MessageEntity, ...] = parse_sequence_arg(text_entities) + self.media: InputPollOptionMedia | None = media self._id_attrs = (self.text,) self._freeze() + # tags: deprecated NEXT.VERSION @classmethod def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "InputPollOption": - """See :meth:`telegram.TelegramObject.de_json`.""" + """See :meth:`telegram.TelegramObject.de_json`. The :paramref:`media` field will + not be included for deserialization. + + .. deprecated:: NEXT.VERSION + This class is input only and will be removed in the next version. + """ + warn( + PTBDeprecationWarning( + "NEXT.VERSION", + "`InputPollOption.de_json` is deprecated. This class is input only and will be " + "removed in the next version. The `media` field will not be included for " + "deserialization.", + ), + stacklevel=2, + ) data = cls._parse_data(data) data["text_entities"] = de_list_optional(data.get("text_entities"), MessageEntity, bot) @@ -117,7 +261,12 @@ class PollOption(TelegramObject): This object contains information about one answer option in a poll. Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal, if their :attr:`text` and :attr:`voter_count` are equal. + considered equal, if their :attr:`text`, :attr:`voter_count` and :attr:`persistent_id` + are equal. + + .. versionchanged:: NEXT.VERSION + Added attribute :attr:`persistent_id` to equality checks. + Args: persistent_id (:obj:`str`): Unique identifier of the option, persistent on option addition @@ -133,6 +282,9 @@ class PollOption(TelegramObject): poll option texts. .. versionadded:: 21.2 + media (:class:`telegram.PollMedia`, optional): Media added to the poll option. + + .. versionadded:: NEXT.VERSION added_by_user (:class:`telegram.User`, optional): User who added the option; omitted if the option wasn't added by a user after poll creation. @@ -161,6 +313,9 @@ class PollOption(TelegramObject): This list is empty if the question does not contain entities. .. versionadded:: 21.2 + media (:class:`telegram.PollMedia`): Optional. Media added to the poll option. + + .. versionadded:: NEXT.VERSION added_by_user (:class:`telegram.User`): Optional. User who added the option; omitted if the option wasn't added by a user after poll creation. @@ -179,6 +334,7 @@ class PollOption(TelegramObject): "added_by_chat", "added_by_user", "addition_date", + "media", "persistent_id", "text", "text_entities", @@ -193,23 +349,28 @@ def __init__( added_by_user: User | None = None, added_by_chat: Chat | None = None, addition_date: dtm.datetime | None = None, + media: PollMedia | None = None, # tags: required in NEXT.VERSION, bot api 9.6 # temporarily optional to avoid breaking changes persistent_id: str | None = None, *, api_kwargs: JSONDict | None = None, ): + if persistent_id is None: + raise TypeError("`persistent_id` is a required argument since Bot API 9.6") + super().__init__(api_kwargs=api_kwargs) self.text: str = text self.voter_count: int = voter_count self.added_by_user: User | None = added_by_user self.added_by_chat: Chat | None = added_by_chat self.addition_date: dtm.datetime | None = addition_date - self.persistent_id: str | None = persistent_id + self.persistent_id: str = persistent_id + self.media: PollMedia | None = media self.text_entities: tuple[MessageEntity, ...] = parse_sequence_arg(text_entities) - self._id_attrs = (self.text, self.voter_count) + self._id_attrs = (self.text, self.voter_count, self.persistent_id) self._freeze() @@ -225,6 +386,7 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PollOption": data["added_by_user"] = de_json_optional(data.get("added_by_user"), User, bot) data["added_by_chat"] = de_json_optional(data.get("added_by_chat"), Chat, bot) data["addition_date"] = from_timestamp(data.get("addition_date"), tzinfo=loc_tzinfo) + data["media"] = de_json_optional(data.get("media"), PollMedia, bot) return super().de_json(data=data, bot=bot) @@ -637,6 +799,11 @@ class Poll(TelegramObject): is_anonymous (:obj:`bool`): :obj:`True`, if the poll is anonymous. type (:obj:`str`): Poll type, currently can be :attr:`REGULAR` or :attr:`QUIZ`. allows_multiple_answers (:obj:`bool`): :obj:`True`, if the poll allows multiple answers. + members_only (:obj:`bool`): :obj:`True`, if voting is limited to users who have been + members of the chat where the poll was originally sent for more than + :tg-const:`telegram.Poll.MIN_MEMBERSHIP_HOURS` hours. + + .. versionadded:: NEXT.VERSION correct_option_id (:obj:`int`, optional): A zero based identifier of the correct answer option. Available only for closed polls in the quiz mode, which were sent (not forwarded), by the bot or to a private chat with the bot. @@ -655,6 +822,10 @@ class Poll(TelegramObject): * This attribute is now always a (possibly empty) list and never :obj:`None`. * |sequenceclassargs| + explanation_media (:class:`telegram.PollMedia`, optional): Media added to the quiz + explanation. + + .. versionadded:: NEXT.VERSION open_period (:obj:`int` | :class:`datetime.timedelta`, optional): Amount of time in seconds the poll will be active after creation. @@ -678,6 +849,12 @@ class Poll(TelegramObject): the correct answer options. Available only for polls in quiz mode which are closed or were sent (not forwarded) by the bot or to the private chat with the bot. + .. versionadded:: NEXT.VERSION + country_codes (Sequence[:obj:`str`], optional): A list of two-letter ``ISO 3166-1 alpha-2`` + country codes indicating the countries from which users can vote in the poll. The + country code ``“FT”`` is used for users with anonymous numbers. If omitted, then users + from any country can participate in the poll. + .. versionadded:: NEXT.VERSION description (:obj:`str`, optional): Description of the poll; for polls inside the :class:`~telegram.Message` object only. @@ -686,6 +863,10 @@ class Poll(TelegramObject): description_entities (Sequence[:class:`telegram.MessageEntity`], optional): Special entities like usernames, URLs, bot commands, etc. that appear in the description + .. versionadded:: NEXT.VERSION + media (:class:`telegram.PollMedia`, optional): Media added to the poll description; + for polls inside the :class:`~telegram.Message` object only. + .. versionadded:: NEXT.VERSION Attributes: @@ -701,12 +882,11 @@ class Poll(TelegramObject): is_anonymous (:obj:`bool`): :obj:`True`, if the poll is anonymous. type (:obj:`str`): Poll type, currently can be :attr:`REGULAR` or :attr:`QUIZ`. allows_multiple_answers (:obj:`bool`): :obj:`True`, if the poll allows multiple answers. - correct_option_id (:obj:`int`): Optional. A zero based identifier of the correct answer - option. Available only for closed polls in the quiz mode, which were sent - (not forwarded), by the bot or to a private chat with the bot. + members_only (:obj:`bool`): :obj:`True`, if voting is limited to users who have been + members of the chat where the poll was originally sent for more than + :tg-const:`telegram.Poll.MIN_MEMBERSHIP_HOURS` hours. - .. deprecated:: NEXT.VERSION - Use :attr:`correct_option_ids` instead. + .. versionadded:: NEXT.VERSION explanation (:obj:`str`): Optional. Text that is shown when a user chooses an incorrect answer or taps on the lamp icon in a quiz-style poll, 0-:tg-const:`telegram.Poll.MAX_EXPLANATION_LENGTH` characters. @@ -719,6 +899,10 @@ class Poll(TelegramObject): .. versionchanged:: 20.0 This attribute is now always a (possibly empty) list and never :obj:`None`. + explanation_media (:class:`telegram.PollMedia`): Optional. Media added to the quiz + explanation. + + .. versionadded:: NEXT.VERSION open_period (:obj:`int` | :class:`datetime.timedelta`): Optional. Amount of time in seconds the poll will be active after creation. @@ -735,7 +919,7 @@ class Poll(TelegramObject): This list is empty if the question does not contain entities. .. versionadded:: 21.2 - allows_revoting (:obj:`bool`): Optional. :obj:`True`, if the poll + allows_revoting (:obj:`bool`): :obj:`True`, if the poll allows to change the chosenanswer options .. versionadded:: NEXT.VERSION @@ -743,6 +927,12 @@ class Poll(TelegramObject): correct answer options. Available only for polls in quiz mode which are closed or were sent (not forwarded) by the bot or to the private chat with the bot. + .. versionadded:: NEXT.VERSION + country_codes (tuple[:obj:`str`]): Optional. A list of two-letter ``ISO 3166-1 alpha-2`` + country codes indicating the countries from which users can vote in the poll. The + country code ``“FT”`` is used for users with anonymous numbers. If omitted, then users + from any country can participate in the poll. + .. versionadded:: NEXT.VERSION description (:obj:`str`): Optional. Description of the poll; for polls inside the Message object only @@ -751,6 +941,10 @@ class Poll(TelegramObject): description_entities (tuple[:class:`telegram.MessageEntity`]): Special entities like usernames, URLs, bot commands, etc. that appear in the description + .. versionadded:: NEXT.VERSION + media (:class:`telegram.PollMedia`): Optional. Media added to the poll description; + for polls inside the Message object only. + .. versionadded:: NEXT.VERSION """ @@ -761,13 +955,17 @@ class Poll(TelegramObject): "allows_revoting", "close_date", "correct_option_ids", + "country_codes", "description", "description_entities", "explanation", "explanation_entities", + "explanation_media", "id", "is_anonymous", "is_closed", + "media", + "members_only", "options", "question", "question_entities", @@ -788,6 +986,7 @@ def __init__( # tags: deprecated NEXT.VERSION # Removed in bot api 9.6: correct_option_id: int | None = None, + # --- explanation: str | None = None, explanation_entities: Sequence[MessageEntity] | None = None, open_period: TimePeriod | None = None, @@ -796,12 +995,23 @@ def __init__( # tags: required in NEXT.VERSION # temporarily optional to avoid breaking changes allows_revoting: bool | None = None, + members_only: bool | None = None, + # --- correct_option_ids: Sequence[int] | None = None, description: str | None = None, description_entities: Sequence[MessageEntity] | None = None, + country_codes: Sequence[str] | None = None, + media: PollMedia | None = None, + explanation_media: PollMedia | None = None, *, api_kwargs: JSONDict | None = None, ): + if allows_revoting is None: + raise TypeError("`allows_revoting` is a required argument since Bot API 9.6") + + if members_only is None: + raise TypeError("`members_only` is a required argument since Bot API 10.0") + super().__init__(api_kwargs=api_kwargs) self.id: str = id self.question: str = question @@ -811,7 +1021,8 @@ def __init__( self.is_anonymous: bool = is_anonymous self.type: str = enum.get_member(constants.PollType, type, type) self.allows_multiple_answers: bool = allows_multiple_answers - self.allows_revoting: bool | None = allows_revoting + self.allows_revoting: bool = allows_revoting + self.members_only: bool = members_only # tag: deprecated NEXT.VERSION if correct_option_id is not None: @@ -838,6 +1049,9 @@ def __init__( self._open_period: dtm.timedelta | None = to_timedelta(open_period) self.close_date: dtm.datetime | None = close_date self.question_entities: tuple[MessageEntity, ...] = parse_sequence_arg(question_entities) + self.country_codes: tuple[str, ...] = parse_sequence_arg(country_codes) + self.media: PollMedia | None = media + self.explanation_media: PollMedia | None = explanation_media self._id_attrs = (self.id,) @@ -866,6 +1080,8 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Poll": data["description_entities"] = de_list_optional( data.get("description_entities"), MessageEntity, bot ) + data["media"] = de_json_optional(data.get("media"), PollMedia, bot) + data["explanation_media"] = de_json_optional(data.get("explanation_media"), PollMedia, bot) return super().de_json(data=data, bot=bot) @@ -1105,3 +1321,8 @@ def correct_option_id(self) -> int | None: .. versionadded:: NEXT.VERSION """ + MIN_MEMBERSHIP_HOURS: Final[int] = constants.PollLimit.MIN_MEMBERSHIP_HOURS + """:const:`telegram.constants.PollLimit.MIN_MEMBERSHIP_HOURS` + + .. versionadded:: NEXT.VERSION + """ diff --git a/src/telegram/_user.py b/src/telegram/_user.py index 821f230adb6..faaf54a199c 100644 --- a/src/telegram/_user.py +++ b/src/telegram/_user.py @@ -50,6 +50,7 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputPollMedia, InputPollOption, LabeledPrice, LinkPreviewOptions, @@ -1750,6 +1751,10 @@ async def send_poll( description: str | None = None, description_parse_mode: str | None = None, description_entities: Sequence["MessageEntity"] | None = None, + members_only: bool | None = None, + country_codes: Sequence[str] | None = None, + explanation_media: "InputPollMedia | None" = None, + media: "InputPollMedia | None" = None, *, reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, @@ -1811,6 +1816,10 @@ async def send_poll( description_entities=description_entities, hide_results_until_closes=hide_results_until_closes, allow_adding_options=allow_adding_options, + members_only=members_only, + country_codes=country_codes, + explanation_media=explanation_media, + media=media, ) async def send_gift( diff --git a/src/telegram/constants.py b/src/telegram/constants.py index fb0be0b48d2..1e34f83a773 100644 --- a/src/telegram/constants.py +++ b/src/telegram/constants.py @@ -45,6 +45,7 @@ "BackgroundFillType", "BackgroundTypeLimit", "BackgroundTypeType", + "BaseInputMediaType", "BotCommandLimit", "BotCommandScopeType", "BotDescriptionLimit", @@ -1519,10 +1520,41 @@ class InputChecklistLimit(IntEnum): """ +class BaseInputMediaType(StringEnum): + """This enum contains the available types of :class:`telegram.InputMedia`, + :class:`telegram.InputPollMedia` and :class:`telegram.InputPollOptionMedia`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: NEXT.VERSION + """ + + __slots__ = () + + ANIMATION = "animation" + """:obj:`str`: Type of :class:`telegram.InputMediaAnimation`.""" + DOCUMENT = "document" + """:obj:`str`: Type of :class:`telegram.InputMediaDocument`.""" + AUDIO = "audio" + """:obj:`str`: Type of :class:`telegram.InputMediaAudio`.""" + PHOTO = "photo" + """:obj:`str`: Type of :class:`telegram.InputMediaPhoto`.""" + VIDEO = "video" + """:obj:`str`: Type of :class:`telegram.InputMediaVideo`.""" + LOCATION = "location" + """:obj:`str`: Type of :class:`telegram.InputMediaLocation`.""" + STICKER = "sticker" + """:obj:`str`: Type of :class:`telegram.InputMediaSticker`.""" + VENUE = "venue" + """:obj:`str`: Type of :class:`telegram.InputMediaVenue`.""" + + class InputMediaType(StringEnum): """This enum contains the available types of :class:`telegram.InputMedia`. The enum members of this enumeration are instances of :class:`str` and can be treated as such. + .. deprecated:: NEXT.VERSION + Use :class:`telegram.constants.BaseInputMediaType` instead. + .. versionadded:: 20.0 """ @@ -1784,6 +1816,8 @@ class LocationLimit(IntEnum): :meth:`telegram.Bot.edit_message_live_location` * :paramref:`~telegram.Bot.send_location.horizontal_accuracy` parameter of :meth:`telegram.Bot.send_location` + * :paramref:`~telegram.InputMediaLocation.horizontal_accuracy` parameter of + :class:`telegram.InputMediaLocation` """ MIN_HEADING = 1 @@ -3422,10 +3456,13 @@ class PollLimit(IntEnum): to the :paramref:`~telegram.Bot.send_poll.options` parameter of :meth:`telegram.Bot.send_poll`. """ - MIN_OPTION_NUMBER = 2 + MIN_OPTION_NUMBER = 1 """:obj:`int`: Minimum number of strings passed in a :obj:`list` to the :paramref:`~telegram.Bot.send_poll.options` parameter of :meth:`telegram.Bot.send_poll`. + + .. versionchanged:: NEXT.VERSION + Bot API 10.0 decreased this value from ``2`` to ``1``. """ MAX_OPTION_NUMBER = 12 """:obj:`int`: Maximum number of strings passed in a :obj:`list` @@ -3466,6 +3503,19 @@ class PollLimit(IntEnum): .. versionadded:: NEXT.VERSION """ + MIN_MEMBERSHIP_HOURS = 24 + """:obj:`int`: Minimum number of hours a user must have been a member of the chat + before they can vote in a members-only poll. + + .. versionadded:: NEXT.VERSION + """ + MAX_COUNTRY_CODES = 12 + """:obj:`int`: Maximum number of two-letter ``ISO 3166-1 alpha-2`` country codes passed in a + :obj:`list` to the :paramref:`~telegram.Bot.send_poll.country_codes` parameter of + :meth:`telegram.Bot.send_poll`. + + .. versionadded:: NEXT.VERSION + """ class PollType(StringEnum): diff --git a/src/telegram/ext/_extbot.py b/src/telegram/ext/_extbot.py index 931115f7beb..fb2e8783b74 100644 --- a/src/telegram/ext/_extbot.py +++ b/src/telegram/ext/_extbot.py @@ -121,6 +121,7 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputPollMedia, InputSticker, InputStoryContent, LabeledPrice, @@ -3265,6 +3266,10 @@ async def send_poll( description_parse_mode: str | None = None, description_entities: Sequence["MessageEntity"] | None = None, shuffle_options: bool | None = None, + members_only: bool | None = None, + country_codes: Sequence[str] | None = None, + explanation_media: "InputPollMedia | None" = None, + media: "InputPollMedia | None" = None, *, reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, @@ -3314,6 +3319,10 @@ async def send_poll( description_entities=description_entities, hide_results_until_closes=hide_results_until_closes, allow_adding_options=allow_adding_options, + members_only=members_only, + country_codes=country_codes, + explanation_media=explanation_media, + media=media, ) async def send_sticker( diff --git a/tests/_files/test_inputmedia.py b/tests/_files/test_inputmedia.py index d43a853ec7d..884c5b0cdb4 100644 --- a/tests/_files/test_inputmedia.py +++ b/tests/_files/test_inputmedia.py @@ -29,15 +29,20 @@ InputMediaAnimation, InputMediaAudio, InputMediaDocument, + InputMediaLocation, InputMediaPhoto, + InputMediaSticker, + InputMediaVenue, InputMediaVideo, InputPaidMediaPhoto, InputPaidMediaVideo, + InputPollMedia, + InputPollOptionMedia, Message, MessageEntity, ReplyParameters, ) -from telegram.constants import InputMediaType, ParseMode +from telegram.constants import BaseInputMediaType, ParseMode from telegram.error import BadRequest from telegram.request import RequestData from telegram.warnings import PTBDeprecationWarning @@ -121,6 +126,37 @@ def input_media_document(class_thumb_file): ) +@pytest.fixture(scope="module") +def input_media_location(): + return InputMediaLocation( + latitude=InputMediaLocationTestBase.latitude, + longitude=InputMediaLocationTestBase.longitude, + horizontal_accuracy=InputMediaLocationTestBase.horizontal_accuracy, + ) + + +@pytest.fixture(scope="module") +def input_media_venue(): + return InputMediaVenue( + latitude=InputMediaVenueTestBase.latitude, + longitude=InputMediaVenueTestBase.longitude, + title=InputMediaVenueTestBase.title, + address=InputMediaVenueTestBase.address, + foursquare_id=InputMediaVenueTestBase.foursquare_id, + foursquare_type=InputMediaVenueTestBase.foursquare_type, + google_place_id=InputMediaVenueTestBase.google_place_id, + google_place_type=InputMediaVenueTestBase.google_place_type, + ) + + +@pytest.fixture(scope="module") +def input_media_sticker(): + return InputMediaSticker( + media=InputMediaStickerTestBase.media, + emoji=InputMediaStickerTestBase.emoji, + ) + + @pytest.fixture(scope="module") def input_paid_media_photo(): return InputPaidMediaPhoto( @@ -157,6 +193,39 @@ class InputMediaVideoTestBase: show_caption_above_media = True +class TestInputMediaWithoutRequest: + def test_type_enum_conversion(self): + assert type(InputMedia(media_type="video", media="media").type) is BaseInputMediaType + assert InputMedia(media_type="unknown", media="media").type == "unknown" + + def test_to_dict(self): + assert InputMedia( + media_type="video", + media="media", + ).to_dict() == { + "type": BaseInputMediaType.VIDEO, + "media": "media", + } + + +class TestInputPollMediaWithoutRequest: + def test_type_enum_conversion(self): + assert type(InputPollMedia("photo").type) is BaseInputMediaType + assert InputPollMedia("unknown").type == "unknown" + + def test_to_dict(self): + assert InputPollMedia("photo").to_dict() == {"type": BaseInputMediaType.PHOTO} + + +class TestInputPollOptionMediaWithoutRequest: + def test_type_enum_conversion(self): + assert type(InputPollOptionMedia("sticker").type) is BaseInputMediaType + assert InputPollOptionMedia("unknown").type == "unknown" + + def test_to_dict(self): + assert InputPollOptionMedia("sticker").to_dict() == {"type": BaseInputMediaType.STICKER} + + class TestInputMediaVideoWithoutRequest(InputMediaVideoTestBase): def test_slot_behaviour(self, input_media_video): inst = input_media_video @@ -179,6 +248,9 @@ def test_expected_values(self, input_media_video): assert input_media_video.start_timestamp == self.start_timestamp assert input_media_video.has_spoiler == self.has_spoiler assert input_media_video.show_caption_above_media == self.show_caption_above_media + assert isinstance(input_media_video, InputMedia) + assert isinstance(input_media_video, InputPollMedia) + assert isinstance(input_media_video, InputPollOptionMedia) def test_caption_entities_always_tuple(self): input_media_video = InputMediaVideo(self.media) @@ -253,26 +325,6 @@ def test_with_local_files(self): assert input_media_video.thumbnail == data_file("telegram.jpg").as_uri() assert input_media_video.cover == data_file("telegram.jpg").as_uri() - def test_type_enum_conversion(self): - # Since we have a lot of different test classes for all the input media types, we test this - # conversion only here. It is independent of the specific class - assert ( - type( - InputMedia( - media_type="animation", - media="media", - ).type - ) - is InputMediaType - ) - assert ( - InputMedia( - media_type="unknown", - media="media", - ).type - == "unknown" - ) - class InputMediaPhotoTestBase: type_ = "photo" @@ -299,6 +351,9 @@ def test_expected_values(self, input_media_photo): assert input_media_photo.caption_entities == tuple(self.caption_entities) assert input_media_photo.has_spoiler == self.has_spoiler assert input_media_photo.show_caption_above_media == self.show_caption_above_media + assert isinstance(input_media_photo, InputMedia) + assert isinstance(input_media_photo, InputPollMedia) + assert isinstance(input_media_photo, InputPollOptionMedia) def test_caption_entities_always_tuple(self): input_media_photo = InputMediaPhoto(self.media) @@ -368,6 +423,9 @@ def test_expected_values(self, input_media_animation): assert input_media_animation.has_spoiler == self.has_spoiler assert input_media_animation.show_caption_above_media == self.show_caption_above_media assert input_media_animation._duration == self.duration + assert isinstance(input_media_animation, InputMedia) + assert isinstance(input_media_animation, InputPollMedia) + assert isinstance(input_media_animation, InputPollOptionMedia) def test_caption_entities_always_tuple(self): input_media_animation = InputMediaAnimation(self.media) @@ -462,6 +520,9 @@ def test_expected_values(self, input_media_audio): assert input_media_audio.parse_mode == self.parse_mode assert input_media_audio.caption_entities == tuple(self.caption_entities) assert isinstance(input_media_audio.thumbnail, InputFile) + assert isinstance(input_media_audio, InputMedia) + assert isinstance(input_media_audio, InputPollMedia) + assert not isinstance(input_media_audio, InputPollOptionMedia) def test_caption_entities_always_tuple(self): input_media_audio = InputMediaAudio(self.media) @@ -536,6 +597,133 @@ class InputMediaDocumentTestBase: disable_content_type_detection = True +class InputMediaLocationTestBase: + type_ = "location" + latitude = 1.0 + longitude = 2.0 + horizontal_accuracy = 10.0 + + +class TestInputMediaLocationWithoutRequest(InputMediaLocationTestBase): + def test_slot_behaviour(self, input_media_location): + inst = input_media_location + 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_expected_values(self, input_media_location): + assert input_media_location.type == self.type_ + assert input_media_location.latitude == self.latitude + assert input_media_location.longitude == self.longitude + assert input_media_location.horizontal_accuracy == self.horizontal_accuracy + assert isinstance(input_media_location, InputPollMedia) + assert isinstance(input_media_location, InputPollOptionMedia) + assert not isinstance(input_media_location, InputMedia) + + def test_to_dict(self, input_media_location): + input_media_location_dict = input_media_location.to_dict() + assert input_media_location_dict["type"] == input_media_location.type + assert input_media_location_dict["latitude"] == input_media_location.latitude + assert input_media_location_dict["longitude"] == input_media_location.longitude + assert ( + input_media_location_dict["horizontal_accuracy"] + == input_media_location.horizontal_accuracy + ) + + +class InputMediaVenueTestBase: + type_ = "venue" + latitude = 1.0 + longitude = 2.0 + title = "title" + address = "address" + foursquare_id = "foursquare_id" + foursquare_type = "food/icecream" + google_place_id = "google_place_id" + google_place_type = "restaurant" + + +class TestInputMediaVenueWithoutRequest(InputMediaVenueTestBase): + def test_slot_behaviour(self, input_media_venue): + inst = input_media_venue + 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_expected_values(self, input_media_venue): + assert input_media_venue.type == self.type_ + assert input_media_venue.latitude == self.latitude + assert input_media_venue.longitude == self.longitude + assert input_media_venue.title == self.title + assert input_media_venue.address == self.address + assert input_media_venue.foursquare_id == self.foursquare_id + assert input_media_venue.foursquare_type == self.foursquare_type + assert input_media_venue.google_place_id == self.google_place_id + assert input_media_venue.google_place_type == self.google_place_type + assert isinstance(input_media_venue, InputPollMedia) + assert isinstance(input_media_venue, InputPollOptionMedia) + assert not isinstance(input_media_venue, InputMedia) + + def test_to_dict(self, input_media_venue): + input_media_venue_dict = input_media_venue.to_dict() + assert input_media_venue_dict["type"] == input_media_venue.type + assert input_media_venue_dict["latitude"] == input_media_venue.latitude + assert input_media_venue_dict["longitude"] == input_media_venue.longitude + assert input_media_venue_dict["title"] == input_media_venue.title + assert input_media_venue_dict["address"] == input_media_venue.address + assert input_media_venue_dict["foursquare_id"] == input_media_venue.foursquare_id + assert input_media_venue_dict["foursquare_type"] == input_media_venue.foursquare_type + assert input_media_venue_dict["google_place_id"] == input_media_venue.google_place_id + assert input_media_venue_dict["google_place_type"] == input_media_venue.google_place_type + + +class InputMediaStickerTestBase: + type_ = "sticker" + media = "NOTAREALFILEID" + emoji = "💪" + + +class TestInputMediaStickerWithoutRequest(InputMediaStickerTestBase): + def test_slot_behaviour(self, input_media_sticker): + inst = input_media_sticker + 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_expected_values(self, input_media_sticker): + assert input_media_sticker.type == self.type_ + assert input_media_sticker.media == self.media + assert input_media_sticker.emoji == self.emoji + assert isinstance(input_media_sticker, InputPollOptionMedia) + assert not isinstance(input_media_sticker, InputPollMedia) + assert not isinstance(input_media_sticker, InputMedia) + + def test_to_dict(self, input_media_sticker): + input_media_sticker_dict = input_media_sticker.to_dict() + assert input_media_sticker_dict["type"] == input_media_sticker.type + assert input_media_sticker_dict["media"] == input_media_sticker.media + assert input_media_sticker_dict["emoji"] == input_media_sticker.emoji + + def test_with_sticker(self, sticker): + input_media_sticker = InputMediaSticker(sticker, emoji=self.emoji) + assert input_media_sticker.type == self.type_ + assert input_media_sticker.media == sticker.file_id + assert input_media_sticker.emoji == self.emoji + + def test_with_sticker_file(self, sticker_file): + input_media_sticker = InputMediaSticker(sticker_file, emoji=self.emoji) + assert input_media_sticker.type == self.type_ + assert isinstance(input_media_sticker.media, InputFile) + assert input_media_sticker.emoji == self.emoji + + def test_with_local_files(self): + input_media_sticker = InputMediaSticker( + data_file("telegram_sticker.png"), emoji=self.emoji + ) + assert input_media_sticker.media == data_file("telegram_sticker.png").as_uri() + assert input_media_sticker.emoji == self.emoji + + class TestInputMediaDocumentWithoutRequest(InputMediaDocumentTestBase): def test_slot_behaviour(self, input_media_document): inst = input_media_document @@ -554,6 +742,9 @@ def test_expected_values(self, input_media_document): == self.disable_content_type_detection ) assert isinstance(input_media_document.thumbnail, InputFile) + assert isinstance(input_media_document, InputMedia) + assert isinstance(input_media_document, InputPollMedia) + assert not isinstance(input_media_document, InputPollOptionMedia) def test_caption_entities_always_tuple(self): input_media_document = InputMediaDocument(self.media) diff --git a/tests/auxil/bot_method_checks.py b/tests/auxil/bot_method_checks.py index 6714e65655a..8624b45a739 100644 --- a/tests/auxil/bot_method_checks.py +++ b/tests/auxil/bot_method_checks.py @@ -45,7 +45,7 @@ ) from telegram._utils.datetime import to_timestamp from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue -from telegram.constants import InputMediaType +from telegram.constants import BaseInputMediaType from telegram.ext import Defaults, ExtBot from telegram.request import RequestData from tests.auxil.dummy_objects import get_dummy_object_json_dict @@ -512,7 +512,7 @@ def check_input_media(m: dict): media = data.pop("media", None) paid_media = media and data.pop("star_count", None) if media and not paid_media: - if isinstance(media, dict) and isinstance(media.get("type", None), InputMediaType): + if isinstance(media, dict) and isinstance(media.get("type", None), BaseInputMediaType): check_input_media(media) else: for m in media: diff --git a/tests/auxil/dummy_objects.py b/tests/auxil/dummy_objects.py index c0ff0ce0cf5..8cef00fdeb7 100644 --- a/tests/auxil/dummy_objects.py +++ b/tests/auxil/dummy_objects.py @@ -127,12 +127,14 @@ "Poll": Poll( id="dummy_id", question="dummy_question", - options=[PollOption(text="dummy_text", voter_count=1)], + options=[PollOption(text="dummy_text", voter_count=1, persistent_id="persistent_id")], is_closed=False, is_anonymous=False, total_voter_count=1, type="dummy_type", allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ), "PreparedKeyboardButton": PreparedKeyboardButton(id=1234), "PreparedInlineMessage": PreparedInlineMessage(id="dummy_id", expiration_date=_DUMMY_DATE), diff --git a/tests/test_bot.py b/tests/test_bot.py index 2fd77550ecb..bb09eea278a 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -53,6 +53,7 @@ InlineQueryResultVoice, InputFile, InputMediaDocument, + InputMediaLocation, InputMediaPhoto, InputMessageContent, InputPollOption, @@ -1794,12 +1795,16 @@ async def post(*args, **kwargs): poll=Poll( "42", "question", - options=[PollOption("option", 0)], + options=[ + PollOption(text="option", voter_count=0, persistent_id="persistent_id") + ], total_voter_count=0, is_closed=False, is_anonymous=True, type=Poll.REGULAR, allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ), ) return [update.to_dict()] @@ -2446,12 +2451,14 @@ async def test_business_connection_id_argument( Poll( id="42", question="question", - options=[PollOption("option", 0)], + options=[PollOption(text="option", voter_count=0, persistent_id="persistent_id")], total_voter_count=5, is_closed=True, is_anonymous=True, type="regular", allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ).to_dict() ) await return_values.put(True) @@ -3275,6 +3282,30 @@ async def test_send_poll_explanation_entities(self, bot, chat_id): assert message.poll.explanation == test_string assert message.poll.explanation_entities == tuple(entities) + async def test_send_poll_media_parameters(self, bot, channel_id, photo): + media = InputMediaPhoto(photo.file_id) + explanation_media = InputMediaDocument(photo.file_id) + option_media = InputMediaLocation(latitude=0, longitude=0) + + message = await bot.send_poll( + channel_id, + question="question", + options=[ + InputPollOption("option1", media=option_media), + InputPollOption("option2"), + ], + type=Poll.QUIZ, + correct_option_ids=[0], + media=media, + explanation_media=explanation_media, + is_closed=True, + read_timeout=60, + ) + + assert message.poll.media.photo + assert message.poll.explanation_media.document + assert message.poll.options[0].media.location + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) async def test_send_poll_default_parse_mode(self, default_bot, super_group_id): explanation = "Italic Bold Code" diff --git a/tests/test_message.py b/tests/test_message.py index 958c59b3109..502dcc05b62 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -213,13 +213,18 @@ def message(bot): "poll": Poll( id="abc", question="What is this?", - options=[PollOption(text="a", voter_count=1), PollOption(text="b", voter_count=2)], + options=[ + PollOption(text="a", voter_count=1, persistent_id="persistent_id_a"), + PollOption(text="b", voter_count=2, persistent_id="persistent_id_b"), + ], is_closed=False, total_voter_count=0, is_anonymous=False, type=Poll.REGULAR, allows_multiple_answers=True, explanation_entities=[], + allows_revoting=True, + members_only=True, ) }, { diff --git a/tests/test_official/exceptions.py b/tests/test_official/exceptions.py index f62ef853990..a0cdf66119a 100644 --- a/tests/test_official/exceptions.py +++ b/tests/test_official/exceptions.py @@ -146,8 +146,9 @@ class ParamTypeCheckingExceptions: "PassportFile": {"credentials"}, "EncryptedPassportElement": {"credentials"}, "PassportElementError": {"source", "type", "message"}, + "InputPoll(Option)?Media": {"media_type"}, "InputMedia": {"caption", "caption_entities", "media", "media_type", "parse_mode"}, - "InputMedia(Animation|Audio|Document|Photo|Video|VideoNote|Voice)": {"filename"}, + "InputMedia(Animation|Audio|Document|Photo|Sticker|Video|VideoNote|Voice)": {"filename"}, "InputFile": {"attach", "filename", "obj", "read_file_handle"}, "MaybeInaccessibleMessage": {"date", "message_id", "chat"}, # attributes common to all subcls "ChatBoostSource": {"source"}, # attributes common to all subclasses @@ -222,7 +223,7 @@ def ignored_param_requirements(object_name: str) -> set[str]: BACKWARDS_COMPAT_KWARGS: dict[str, set[str]] = { "PollOption": {"persistent_id"}, "PollAnswer": {"option_persistent_ids"}, - "Poll": {"allows_revoting"}, + "Poll": {"allows_revoting", "members_only"}, } diff --git a/tests/test_poll.py b/tests/test_poll.py index 0f1998ed17d..2ad1c7c64c3 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -24,11 +24,21 @@ InputPollOption, MaybeInaccessibleMessage, MessageEntity, + PhotoSize, Poll, PollAnswer, + PollMedia, PollOption, User, ) +from telegram._files.animation import Animation +from telegram._files.audio import Audio +from telegram._files.document import Document +from telegram._files.inputmedia import InputMediaPhoto +from telegram._files.location import Location +from telegram._files.sticker import Sticker +from telegram._files.venue import Venue +from telegram._files.video import Video from telegram._poll import PollOptionAdded, PollOptionDeleted from telegram._utils.datetime import UTC, to_timestamp from telegram.constants import PollType @@ -42,6 +52,7 @@ def input_poll_option(): text=InputPollOptionTestBase.text, text_parse_mode=InputPollOptionTestBase.text_parse_mode, text_entities=InputPollOptionTestBase.text_entities, + media=InputPollOptionTestBase.media, ) out._unfreeze() return out @@ -54,6 +65,7 @@ class InputPollOptionTestBase: MessageEntity(0, 4, MessageEntity.BOLD), MessageEntity(5, 7, MessageEntity.ITALIC), ] + media = InputMediaPhoto("media") class TestInputPollOptionWithoutRequest(InputPollOptionTestBase): @@ -64,6 +76,7 @@ def test_slot_behaviour(self, input_poll_option): "duplicate slot" ) + # tags: deprecated NEXT.VERSION def test_de_json(self): json_dict = { "text": self.text, @@ -77,6 +90,16 @@ def test_de_json(self): assert input_poll_option.text_parse_mode == self.text_parse_mode assert input_poll_option.text_entities == tuple(self.text_entities) + def test_de_json_deprecated(self, recwarn): + InputPollOption.de_json({"text": self.text}, None) + + assert len(recwarn) == 1 + assert "`InputPollOption.de_json` is deprecated" in str(recwarn[0].message) + assert "The `media` field will not be included for deserialization" in str( + recwarn[0].message + ) + assert recwarn[0].category is PTBDeprecationWarning + def test_to_dict(self, input_poll_option): input_poll_option_dict = input_poll_option.to_dict() @@ -86,6 +109,7 @@ def test_to_dict(self, input_poll_option): assert input_poll_option_dict["text_entities"] == [ e.to_dict() for e in input_poll_option.text_entities ] + assert input_poll_option_dict["media"] == input_poll_option.media.to_dict() # Test that the default-value parameter is handled correctly input_poll_option = InputPollOption("text") @@ -97,7 +121,18 @@ def test_equality(self): b = InputPollOption("text", self.text_parse_mode) c = InputPollOption("text", text_entities=self.text_entities) d = InputPollOption("different_text") - e = Poll(123, "question", ["O1", "O2"], 1, False, True, Poll.REGULAR, True) + e = Poll( + 123, + "question", + ["O1", "O2"], + 1, + False, + True, + Poll.REGULAR, + True, + allows_revoting=True, + members_only=True, + ) assert a == b assert hash(a) == hash(b) @@ -112,6 +147,103 @@ def test_equality(self): assert hash(a) != hash(e) +@pytest.fixture(scope="module") +def poll_media(): + return PollMedia( + animation=PollMediaTestBase.animation, + audio=PollMediaTestBase.audio, + document=PollMediaTestBase.document, + location=PollMediaTestBase.location, + photo=PollMediaTestBase.photo, + sticker=PollMediaTestBase.sticker, + venue=PollMediaTestBase.venue, + video=PollMediaTestBase.video, + # TODO: LivePhoto + ) + + +class PollMediaTestBase: + animation = Animation("blah", "unique_id", 320, 180, 1) + audio = Audio(file_id="file_id", file_unique_id="file_unique_id", duration=30) + document = Document("file_id", "file_unique_id", "file_name", 42) + location = Location(123, 456) + photo = (PhotoSize("file_id", "file_unique_id", 1, 1),) + sticker = Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular") + venue = Venue(location=Location(123, 456), title="title", address="address") + video = Video( + file_id="video_file_id", + width=640, + height=480, + file_unique_id="file_unique_id", + duration=dtm.timedelta(seconds=60), + ) + + +class TestPollMediaWithoutRequest(PollMediaTestBase): + def test_slot_behaviour(self, poll_media): + for attr in poll_media.__slots__: + assert getattr(poll_media, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(poll_media)) == len(set(mro_slots(poll_media))), "duplicate slot" + + def test_de_json(self): + json_dict = { + "animation": self.animation.to_dict(), + "audio": self.audio.to_dict(), + "document": self.document.to_dict(), + "location": self.location.to_dict(), + "photo": [photo.to_dict() for photo in self.photo], + "sticker": self.sticker.to_dict(), + "venue": self.venue.to_dict(), + "video": self.video.to_dict(), + # TODO: LivePhoto + } + poll_media = PollMedia.de_json(json_dict, None) + + assert poll_media.api_kwargs == {} + assert poll_media.animation == self.animation + assert poll_media.audio == self.audio + assert poll_media.document == self.document + assert poll_media.location == self.location + assert poll_media.photo == self.photo + assert poll_media.sticker == self.sticker + assert poll_media.venue == self.venue + assert poll_media.video == self.video + # TODO: LivePhoto + + def test_to_dict(self, poll_media): + poll_media_dict = poll_media.to_dict() + + assert isinstance(poll_media_dict, dict) + assert poll_media_dict["animation"] == poll_media.animation.to_dict() + assert poll_media_dict["audio"] == poll_media.audio.to_dict() + assert poll_media_dict["document"] == poll_media.document.to_dict() + assert poll_media_dict["location"] == poll_media.location.to_dict() + assert poll_media_dict["photo"] == [photo.to_dict() for photo in poll_media.photo] + assert poll_media_dict["sticker"] == poll_media.sticker.to_dict() + assert poll_media_dict["venue"] == poll_media.venue.to_dict() + assert poll_media_dict["video"] == poll_media.video.to_dict() + # TODO: LivePhoto + + def test_equality(self): + a = PollMedia(photo=self.photo) + b = PollMedia(photo=self.photo) + c = PollMedia(photo=(PhotoSize("file_id", "other_file_unique_id", 1, 1),)) + d = PollMedia(video=self.video) + e = PollOption("text", 1, persistent_id="persistent_id") + + assert a == b + assert hash(a) == hash(b) + + assert a != d + assert hash(a) != hash(d) + + assert a != c + assert hash(a) != hash(c) + + assert a != e + assert hash(a) != hash(e) + + @pytest.fixture(scope="module") def poll_option(): out = PollOption( @@ -121,6 +253,8 @@ def poll_option(): added_by_user=PollOptionTestBase.added_by_user, added_by_chat=PollOptionTestBase.added_by_chat, addition_date=PollOptionTestBase.addition_date, + persistent_id=PollOptionTestBase.persistent_id, + media=PollOptionTestBase.media, ) out._unfreeze() return out @@ -136,6 +270,8 @@ class PollOptionTestBase: added_by_user = User(1, "test_user", False) added_by_chat = Chat(1, "test_chat") addition_date = dtm.datetime.now(dtm.timezone.utc) + persistent_id = "persistent_id" + media = PollMedia(location=Location(123, 456)) class TestPollOptionWithoutRequest(PollOptionTestBase): @@ -152,6 +288,8 @@ def test_de_json(self): "added_by_user": self.added_by_user.to_dict(), "added_by_chat": self.added_by_chat.to_dict(), "addition_date": to_timestamp(self.addition_date), + "persistent_id": self.persistent_id, + "media": self.media.to_dict(), } poll_option = PollOption.de_json(json_dict, None) assert poll_option.api_kwargs == {} @@ -162,6 +300,8 @@ def test_de_json(self): assert poll_option.added_by_user == self.added_by_user assert poll_option.added_by_chat == self.added_by_chat assert abs((poll_option.addition_date - self.addition_date).total_seconds()) < 1 + assert poll_option.persistent_id == self.persistent_id + assert poll_option.media == self.media def test_to_dict(self, poll_option): poll_option_dict = poll_option.to_dict() @@ -175,6 +315,8 @@ def test_to_dict(self, poll_option): assert poll_option_dict["added_by_user"] == poll_option.added_by_user.to_dict() assert poll_option_dict["added_by_chat"] == poll_option.added_by_chat.to_dict() assert poll_option_dict["addition_date"] == to_timestamp(poll_option.addition_date) + assert poll_option_dict["persistent_id"] == poll_option.persistent_id + assert poll_option_dict["media"] == poll_option.media.to_dict() def test_parse_entity(self, poll_option): entity = MessageEntity(MessageEntity.BOLD, 0, 4) @@ -190,12 +332,29 @@ def test_parse_entities(self, poll_option): assert poll_option.parse_entities(MessageEntity.BOLD) == {entity: "test"} assert poll_option.parse_entities() == {entity: "test", entity_2: "option"} + def test_persistent_id_required_workaround(self): + # tags: deprecated NEXT.VERSION, bot api 9.6 + with pytest.raises(TypeError, match="`persistent_id` is a required"): + PollOption(self.text, self.voter_count) + def test_equality(self): - a = PollOption("text", 1) - b = PollOption("text", 1) - c = PollOption("text_1", 1) - d = PollOption("text", 2) - e = Poll(123, "question", ["O1", "O2"], 1, False, True, Poll.REGULAR, True) + a = PollOption("text", 1, persistent_id="persistent_id") + b = PollOption("text", 1, persistent_id="persistent_id") + c = PollOption("other_text", 1, persistent_id="persistent_id") + d = PollOption("text", 1 + 9, persistent_id="persistent_id") + e = PollOption("text", 1, persistent_id="other_persistent_id") + f = Poll( + 123, + "question", + ["O1", "O2"], + 1, + False, + True, + Poll.REGULAR, + True, + allows_revoting=True, + members_only=True, + ) assert a == b assert hash(a) == hash(b) @@ -209,6 +368,9 @@ def test_equality(self): assert a != e assert hash(a) != hash(e) + assert a != f + assert hash(a) != hash(f) + @pytest.fixture(scope="module") def poll_answer(): @@ -263,7 +425,7 @@ def test_equality(self): 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) + f = PollOption("Text", 1, persistent_id="persistent_id") assert a == b assert hash(a) == hash(b) @@ -298,9 +460,13 @@ def poll(): close_date=PollTestBase.close_date, question_entities=PollTestBase.question_entities, allows_revoting=PollTestBase.allows_revoting, + members_only=PollTestBase.members_only, correct_option_ids=PollTestBase.correct_option_ids, description=PollTestBase.description, description_entities=PollTestBase.description_entities, + country_codes=PollTestBase.country_codes, + media=PollTestBase.media, + explanation_media=PollTestBase.explanation_media, ) poll._unfreeze() return poll @@ -309,12 +475,16 @@ def poll(): class PollTestBase: id_ = "id" question = "Test Question?" - options = [PollOption("test", 10), PollOption("test2", 11)] + options = [ + PollOption("test", 10, persistent_id="persistent_id"), + PollOption("test2", 11, persistent_id="persistent_id_2"), + ] total_voter_count = 0 is_closed = True is_anonymous = False type = Poll.REGULAR allows_multiple_answers = True + members_only = True explanation = ( b"\\U0001f469\\u200d\\U0001f469\\u200d\\U0001f467" b"\\u200d\\U0001f467\\U0001f431http://google.com" @@ -330,6 +500,9 @@ class PollTestBase: correct_option_ids = [1, 2] description = "description" description_entities = [MessageEntity(MessageEntity.ITALIC, 0, 11)] + country_codes = ["AB", "CD"] + media = PollMedia(document=Document("file_id", "file_unique_id", "file_name", 42)) + explanation_media = PollMedia(animation=Animation("blah", "unique_id", 320, 180, 1)) class TestPollWithoutRequest(PollTestBase): @@ -349,9 +522,13 @@ def test_de_json(self, offline_bot): "close_date": to_timestamp(self.close_date), "question_entities": [e.to_dict() for e in self.question_entities], "allows_revoting": self.allows_revoting, + "members_only": self.members_only, "correct_option_ids": self.correct_option_ids, "description": self.description, "description_entities": [e.to_dict() for e in self.description_entities], + "country_codes": self.country_codes, + "media": self.media.to_dict(), + "explanation_media": self.explanation_media.to_dict(), } poll = Poll.de_json(json_dict, offline_bot) assert poll.api_kwargs == {} @@ -368,6 +545,7 @@ def test_de_json(self, offline_bot): assert poll.is_anonymous == self.is_anonymous assert poll.type == self.type assert poll.allows_multiple_answers == self.allows_multiple_answers + assert poll.members_only == self.members_only assert poll.explanation == self.explanation assert poll.explanation_entities == tuple(self.explanation_entities) assert poll._open_period == self.open_period @@ -378,6 +556,9 @@ def test_de_json(self, offline_bot): assert poll.correct_option_ids == tuple(self.correct_option_ids) assert poll.description == self.description assert poll.description_entities == tuple(self.description_entities) + assert poll.country_codes == tuple(self.country_codes) + assert poll.media == self.media + assert poll.explanation_media == self.explanation_media def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): json_dict = { @@ -395,9 +576,13 @@ def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): "close_date": to_timestamp(self.close_date), "question_entities": [e.to_dict() for e in self.question_entities], "allows_revoting": self.allows_revoting, + "members_only": self.members_only, "correct_option_ids": self.correct_option_ids, "description": self.description, "description_entities": [e.to_dict() for e in self.description_entities], + "country_codes": self.country_codes, + "media": self.media.to_dict(), + "explanation_media": self.explanation_media.to_dict(), } poll_raw = Poll.de_json(json_dict, raw_bot) @@ -426,6 +611,7 @@ def test_to_dict(self, poll): assert poll_dict["is_anonymous"] == poll.is_anonymous assert poll_dict["type"] == poll.type assert poll_dict["allows_multiple_answers"] == poll.allows_multiple_answers + assert poll_dict["members_only"] == poll.members_only assert poll_dict["explanation"] == poll.explanation assert poll_dict["explanation_entities"] == [poll.explanation_entities[0].to_dict()] assert poll_dict["open_period"] == int(self.open_period.total_seconds()) @@ -437,6 +623,9 @@ def test_to_dict(self, poll): assert poll_dict["description_entities"] == [ e.to_dict() for e in poll.description_entities ] + assert poll_dict["country_codes"] == list(poll.country_codes) + assert poll_dict["media"] == poll.media.to_dict() + assert poll_dict["explanation_media"] == poll.explanation_media.to_dict() def test_time_period_properties(self, PTB_TIMEDELTA, poll): if PTB_TIMEDELTA: @@ -473,14 +662,79 @@ def test_correct_option_id_deprecated(self, recwarn, poll): PollTestBase.type, PollTestBase.allows_multiple_answers, correct_option_id=1, + allows_revoting=PollTestBase.allows_revoting, + members_only=PollTestBase.members_only, ) assert poll.correct_option_ids == (1,) + def test_allows_revoting_required_workaround(self): + # tags: deprecated NEXT.VERSION, bot api 9.6 + with pytest.raises(TypeError, match="`allows_revoting` is a required"): + Poll( + self.id_, + self.question, + self.options, + self.total_voter_count, + self.is_closed, + self.is_anonymous, + self.type, + self.allows_multiple_answers, + members_only=self.members_only, + ) + + def test_members_only_required_workaround(self): + # tags: deprecated NEXT.VERSION, bot api 10.0 + with pytest.raises(TypeError, match="`members_only` is a required"): + Poll( + self.id_, + self.question, + self.options, + self.total_voter_count, + self.is_closed, + self.is_anonymous, + self.type, + self.allows_multiple_answers, + allows_revoting=self.allows_revoting, + ) + def test_equality(self): - a = Poll(123, "question", ["O1", "O2"], 1, False, True, Poll.REGULAR, True) - b = Poll(123, "question", ["o1", "o2"], 1, True, False, Poll.REGULAR, True) - c = Poll(456, "question", ["o1", "o2"], 1, True, False, Poll.REGULAR, True) - d = PollOption("Text", 1) + a = Poll( + 123, + "question", + ["O1", "O2"], + 1, + False, + True, + Poll.REGULAR, + True, + allows_revoting=True, + members_only=True, + ) + b = Poll( + 123, + "question", + ["o1", "o2"], + 1, + True, + False, + Poll.REGULAR, + True, + allows_revoting=False, + members_only=False, + ) + c = Poll( + 456, + "question", + ["o1", "o2"], + 1, + True, + False, + Poll.REGULAR, + True, + allows_revoting=True, + members_only=True, + ) + d = PollOption("Text", 1, persistent_id="persistent_id") assert a == b assert hash(a) == hash(b) @@ -501,6 +755,8 @@ def test_enum_init(self): is_closed=False, is_anonymous=False, allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ) assert poll.type == "foo" poll = Poll( @@ -512,6 +768,8 @@ def test_enum_init(self): is_closed=False, is_anonymous=False, allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ) assert poll.type is PollType.QUIZ @@ -525,12 +783,14 @@ def test_parse_explanation_entity(self, poll): Poll( "id", "question", - [PollOption("text", voter_count=0)], + [PollOption("text", voter_count=0, persistent_id="persistent_id")], total_voter_count=0, is_closed=False, is_anonymous=False, type=Poll.QUIZ, allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ).parse_explanation_entity(entity) def test_parse_explanation_entities(self, poll): @@ -545,12 +805,14 @@ def test_parse_explanation_entities(self, poll): Poll( "id", "question", - [PollOption("text", voter_count=0)], + [PollOption("text", voter_count=0, persistent_id="persistent_id")], total_voter_count=0, is_closed=False, is_anonymous=False, type=Poll.QUIZ, allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ).parse_explanation_entities() def test_parse_question_entity(self, poll): @@ -576,12 +838,14 @@ def test_parse_description_entity(self, poll): Poll( "id", "question", - [PollOption("text", voter_count=0)], + [PollOption("text", voter_count=0, persistent_id="persistent_id")], total_voter_count=0, is_closed=False, is_anonymous=False, type=Poll.QUIZ, allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ).parse_description_entity(entity) def test_parse_description_entities(self, poll): @@ -595,12 +859,14 @@ def test_parse_description_entities(self, poll): Poll( "id", "question", - [PollOption("text", voter_count=0)], + [PollOption("text", voter_count=0, persistent_id="persistent_id")], total_voter_count=0, is_closed=False, is_anonymous=False, type=Poll.QUIZ, allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ).parse_description_entities() diff --git a/tests/test_update.py b/tests/test_update.py index 8a8e0c4853b..56373bb16d4 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -171,7 +171,20 @@ ) }, {"pre_checkout_query": PreCheckoutQuery("id", User(1, "", False), "", 0, "")}, - {"poll": Poll("id", "?", [PollOption(".", 1)], False, False, False, Poll.REGULAR, True)}, + { + "poll": Poll( + "id", + "?", + [PollOption(text=".", voter_count=1, persistent_id="persistent_id")], + False, + False, + False, + Poll.REGULAR, + True, + allows_revoting=True, + members_only=True, + ) + }, { "poll_answer": PollAnswer( "id",