From e59d903958537d944f1385a7d99091231604742f Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Sun, 10 May 2026 16:37:20 +0300 Subject: [PATCH 1/3] Reapply accediently remove line by (#5196) --- docs/source/telegram.at-tree.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index 25d9c269838..842bbd5971a 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -154,6 +154,7 @@ Available Types telegram.photosize telegram.poll telegram.pollanswer + telegram.polloption telegram.polloptionadded telegram.polloptiondeleted telegram.preparedkeyboardbutton From 1c17ebda74bd91cad37752e056d17bd2978fc3df Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Sun, 10 May 2026 22:24:58 +0300 Subject: [PATCH 2/3] Add new `{Input*,Poll}` classes --- docs/source/telegram.at-tree.rst | 6 + docs/source/telegram.inputmedialocation.rst | 6 + docs/source/telegram.inputmediasticker.rst | 6 + docs/source/telegram.inputmediavenue.rst | 6 + docs/source/telegram.inputpollmedia.rst | 6 + docs/source/telegram.inputpolloptionmedia.rst | 6 + docs/source/telegram.pollmedia.rst | 6 + src/telegram/__init__.py | 12 + src/telegram/_files/inputmedia.py | 289 ++++++++++++++++-- src/telegram/_poll.py | 121 ++++++++ src/telegram/constants.py | 34 +++ tests/_files/test_inputmedia.py | 233 ++++++++++++-- tests/test_official/exceptions.py | 3 +- tests/test_poll.py | 106 +++++++ 14 files changed, 792 insertions(+), 48 deletions(-) create mode 100644 docs/source/telegram.inputmedialocation.rst create mode 100644 docs/source/telegram.inputmediasticker.rst create mode 100644 docs/source/telegram.inputmediavenue.rst create mode 100644 docs/source/telegram.inputpollmedia.rst create mode 100644 docs/source/telegram.inputpolloptionmedia.rst create mode 100644 docs/source/telegram.pollmedia.rst diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index 842bbd5971a..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,7 @@ Available Types telegram.photosize telegram.poll telegram.pollanswer + telegram.pollmedia telegram.polloption telegram.polloptionadded telegram.polloptiondeleted 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/_files/inputmedia.py b/src/telegram/_files/inputmedia.py index 23b6620985a..094cca70c02 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,95 @@ 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 +174,7 @@ class InputMedia(TelegramObject): """ - __slots__ = ("caption", "caption_entities", "media", "parse_mode", "type") + __slots__ = ("caption", "caption_entities", "media", "parse_mode") def __init__( self, @@ -97,14 +186,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 +387,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 +441,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 +508,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 +528,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 +562,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 +604,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 +617,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 +682,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 +763,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 +788,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 +986,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 +1039,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 +1057,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 +1095,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 +1134,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/_poll.py b/src/telegram/_poll.py index ba02062b33a..863a0533adb 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 @@ -49,6 +57,119 @@ from telegram import Bot, 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): """ This object contains information about one answer option in a poll to be sent. diff --git a/src/telegram/constants.py b/src/telegram/constants.py index fb0be0b48d2..8cbfb503718 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 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/test_official/exceptions.py b/tests/test_official/exceptions.py index f62ef853990..0f79d443a08 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 diff --git a/tests/test_poll.py b/tests/test_poll.py index 0f1998ed17d..28c01a78571 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -24,11 +24,20 @@ 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.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 @@ -112,6 +121,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) + + 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( From 66f0fcd98b6f9a9d28ec1f19df629d9e5ab57cd0 Mon Sep 17 00:00:00 2001 From: aelkheir <90580077+aelkheir@users.noreply.github.com> Date: Sun, 10 May 2026 21:06:26 +0000 Subject: [PATCH 3/3] Add chango fragment for PR #5232 --- changes/unreleased/5232.eie6iBtaicy9eTpwYTVPAs.toml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changes/unreleased/5232.eie6iBtaicy9eTpwYTVPAs.toml 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 = []