diff --git a/changes/unreleased/5229.87PBN4GFkuAaDhhgFwCYkY.toml b/changes/unreleased/5229.87PBN4GFkuAaDhhgFwCYkY.toml index 7bb836c47c6..fedb4731015 100644 --- a/changes/unreleased/5229.87PBN4GFkuAaDhhgFwCYkY.toml +++ b/changes/unreleased/5229.87PBN4GFkuAaDhhgFwCYkY.toml @@ -3,4 +3,5 @@ highlights = "Full Support for Bot API 10.0" pull_requests = [ { uid = "5229", author_uid = "aelkheir", closes_threads = ["5228"] }, { uid = "5230", author_uid = "harshil21" }, + { uid = "5235", author_uid = "harshil21" }, ] diff --git a/docs/source/inclusions/bot_methods.rst b/docs/source/inclusions/bot_methods.rst index 8ee2734a81f..bd1987ad3e6 100644 --- a/docs/source/inclusions/bot_methods.rst +++ b/docs/source/inclusions/bot_methods.rst @@ -41,6 +41,8 @@ - Used for sending paid media to channels * - :meth:`~telegram.Bot.send_photo` - Used for sending photos + * - :meth:`~telegram.Bot.send_live_photo` + - Used for sending live photos * - :meth:`~telegram.Bot.send_poll` - Used for sending polls * - :meth:`~telegram.Bot.send_sticker` diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index 25d9c269838..fd6054d4c96 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -101,9 +101,11 @@ Available Types telegram.inputmediaanimation telegram.inputmediaaudio telegram.inputmediadocument + telegram.inputmedialivephoto telegram.inputmediaphoto telegram.inputmediavideo telegram.inputpaidmedia + telegram.inputpaidmedialivephoto telegram.inputpaidmediaphoto telegram.inputpaidmediavideo telegram.inputprofilephoto @@ -119,6 +121,7 @@ Available Types telegram.keyboardbuttonrequestmanagedbot telegram.keyboardbuttonrequestusers telegram.linkpreviewoptions + telegram.livephoto telegram.location telegram.locationaddress telegram.loginurl @@ -146,6 +149,7 @@ Available Types telegram.ownedgiftunique telegram.paidmedia telegram.paidmediainfo + telegram.paidmedialivephoto telegram.paidmediaphoto telegram.paidmediapreview telegram.paidmediapurchased diff --git a/docs/source/telegram.inputmedialivephoto.rst b/docs/source/telegram.inputmedialivephoto.rst new file mode 100644 index 00000000000..975614554a1 --- /dev/null +++ b/docs/source/telegram.inputmedialivephoto.rst @@ -0,0 +1,6 @@ +InputMediaLivePhoto +=================== + +.. autoclass:: telegram.InputMediaLivePhoto + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.inputpaidmedialivephoto.rst b/docs/source/telegram.inputpaidmedialivephoto.rst new file mode 100644 index 00000000000..62bf1a2dac6 --- /dev/null +++ b/docs/source/telegram.inputpaidmedialivephoto.rst @@ -0,0 +1,6 @@ +InputPaidMediaLivePhoto +====================== + +.. autoclass:: telegram.InputPaidMediaLivePhoto + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.livephoto.rst b/docs/source/telegram.livephoto.rst new file mode 100644 index 00000000000..969e4b4db4c --- /dev/null +++ b/docs/source/telegram.livephoto.rst @@ -0,0 +1,6 @@ +LivePhoto +========= + +.. autoclass:: telegram.LivePhoto + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.paidmedialivephoto.rst b/docs/source/telegram.paidmedialivephoto.rst new file mode 100644 index 00000000000..12fe8058ce9 --- /dev/null +++ b/docs/source/telegram.paidmedialivephoto.rst @@ -0,0 +1,6 @@ +PaidMediaLivePhoto +================== + +.. autoclass:: telegram.PaidMediaLivePhoto + :members: + :show-inheritance: \ No newline at end of file diff --git a/src/telegram/__init__.py b/src/telegram/__init__.py index 362f3253198..51417748fcd 100644 --- a/src/telegram/__init__.py +++ b/src/telegram/__init__.py @@ -157,10 +157,12 @@ "InputMediaAnimation", "InputMediaAudio", "InputMediaDocument", + "InputMediaLivePhoto", "InputMediaPhoto", "InputMediaVideo", "InputMessageContent", "InputPaidMedia", + "InputPaidMediaLivePhoto", "InputPaidMediaPhoto", "InputPaidMediaVideo", "InputPollOption", @@ -181,6 +183,7 @@ "KeyboardButtonRequestUsers", "LabeledPrice", "LinkPreviewOptions", + "LivePhoto", "Location", "LocationAddress", "LoginUrl", @@ -210,6 +213,7 @@ "OwnedGifts", "PaidMedia", "PaidMediaInfo", + "PaidMediaLivePhoto", "PaidMediaPhoto", "PaidMediaPreview", "PaidMediaPurchased", @@ -435,9 +439,11 @@ InputMediaAnimation, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, InputMediaPhoto, InputMediaVideo, InputPaidMedia, + InputPaidMediaLivePhoto, InputPaidMediaPhoto, InputPaidMediaVideo, ) @@ -447,6 +453,7 @@ InputProfilePhotoStatic, ) from ._files.inputsticker import InputSticker +from ._files.livephoto import LivePhoto from ._files.location import Location from ._files.photosize import PhotoSize from ._files.sticker import MaskPosition, Sticker, StickerSet @@ -529,6 +536,7 @@ from ._paidmedia import ( PaidMedia, PaidMediaInfo, + PaidMediaLivePhoto, PaidMediaPhoto, PaidMediaPreview, PaidMediaPurchased, diff --git a/src/telegram/_bot.py b/src/telegram/_bot.py index 3491a75cffa..3fa7cad7790 100644 --- a/src/telegram/_bot.py +++ b/src/telegram/_bot.py @@ -65,6 +65,7 @@ from telegram._files.document import Document from telegram._files.file import File from telegram._files.inputmedia import InputMedia, InputPaidMedia +from telegram._files.livephoto import LivePhoto from telegram._files.location import Location from telegram._files.photosize import PhotoSize from telegram._files.sticker import MaskPosition, Sticker, StickerSet @@ -126,6 +127,7 @@ InputFile, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, InputMediaPhoto, InputMediaVideo, InputProfilePhoto, @@ -2839,7 +2841,7 @@ async def send_media_group( self, chat_id: int | str, media: Sequence[ - "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo | InputMediaLivePhoto" # noqa: E501 # pylint: disable=line-too-long ], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, @@ -2878,8 +2880,8 @@ async def send_media_group( chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| media (Sequence[:class:`telegram.InputMediaAudio`,\ :class:`telegram.InputMediaDocument`, :class:`telegram.InputMediaPhoto`,\ - :class:`telegram.InputMediaVideo`]): An array - describing messages to be sent, must include + :class:`telegram.InputMediaVideo`, :class:`telegram.InputMediaLivePhoto`]): An + array describing messages to be sent, must include :tg-const:`telegram.constants.MediaGroupLimit.MIN_MEDIA_LENGTH`- :tg-const:`telegram.constants.MediaGroupLimit.MAX_MEDIA_LENGTH` items. @@ -4658,12 +4660,12 @@ async def edit_message_media( api_kwargs: JSONDict | None = None, ) -> "Message | bool": """ - Use this method to edit animation, audio, document, photo, or video messages, or to add - media to text messages. If a message + Use this method to edit animation, audio, document, live photo, photo, or video messages, + or to add media to text messages. If a message is part of a message album, then it can be edited only to an audio for audio albums, only - to a document for document albums and to a photo or a video otherwise. When an inline - message is edited, a new file can't be uploaded; use a previously uploaded file via its - :attr:`~telegram.File.file_id` or specify a URL. + to a document for document albums and to a photo, live photo, or a video otherwise. + When an inline message is edited, a new file can't be uploaded; use a previously + uploaded file via its :attr:`~telegram.File.file_id` or specify a URL. Note: * |editreplymarkup| @@ -12287,6 +12289,129 @@ async def save_prepared_keyboard_button( self, ) + async def send_live_photo( + self, + chat_id: int | str, + live_photo: "FileInput | LivePhoto", + photo: "FileInput | PhotoSize", + business_connection_id: str | None = None, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + has_spoiler: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + allow_paid_broadcast: bool | None = None, + message_effect_id: str | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "ReplyMarkup | None" = None, + *, + filename: str | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> Message: + """ + Use this method to send live photos. + + .. seealso:: :wiki:`Working with Files and Media ` + + .. versionadded:: NEXT.VERSION + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + live_photo (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | \ + :obj:`bytes` | :class:`pathlib.Path` | :class:`telegram.LivePhoto`): Live photo + video to send. Pass a ``file_id`` to send a file that exists on the Telegram + servers (recommended). |uploadinputnopath| Sending live photos by a URL is + currently unsupported. Lastly you can pass an existing + :class:`telegram.LivePhoto` object to send. + + Caution: + * The video must be at most 10MB in size. + * The video duration must not exceed 10 seconds. + * If you pass a :class:`telegram.LivePhoto`, its + :attr:`~telegram.LivePhoto.photo` field will not be considered, use + :paramref:`photo` to specify the photo to send. + + photo (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` \ + | :class:`pathlib.Path` | :class:`telegram.PhotoSize`): The static photo to send. + Pass a ``file_id`` to send a file that exists on the Telegram servers (recommended) + . |uploadinputnopath| Sending live photos by a URL is currently unsupported. + Lastly you can pass an existing :class:`telegram.PhotoSize` object to send. + business_connection_id (:obj:`str`, optional): |business_id_str| + message_thread_id (:obj:`int`, optional): |message_thread_id_arg| + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + caption (:obj:`str`, optional): Video caption (may also be used when resending videos + by file_id), 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` + characters after entities parsing. + parse_mode (:obj:`str`, optional): |parse_mode| + caption_entities (Sequence[:class:`telegram.MessageEntity`], optional): + |caption_entities| + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| + has_spoiler (:obj:`bool`, optional): Pass :obj:`True` if the video needs to be covered + with a spoiler animation. + disable_notification (:obj:`bool`, optional): |disable_notification| + protect_content (:obj:`bool`, optional): |protect_content| + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + message_effect_id (:obj:`str`, optional): |message_effect_id| + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + reply_markup (:class:`InlineKeyboardMarkup` | :class:`ReplyKeyboardMarkup` | \ + :class:`ReplyKeyboardRemove` | :class:`ForceReply`, optional): + Additional interface options. An object for an inline keyboard, custom reply + keyboard, instructions to remove reply keyboard or to force a reply from the user. + + Keyword Args: + filename (:obj:`str`, optional): Custom file name for :paramref:`photo`, when + uploading a new file. Convenience parameter, useful e.g. when sending files + generated by the :obj:`tempfile` module. + + Returns: + :class:`telegram.Message`: On success, the sent Message is returned. + + Raises: + :class:`telegram.error.TelegramError` + + """ + data: JSONDict = { + "chat_id": chat_id, + "live_photo": self._parse_file_input(live_photo, LivePhoto), + "photo": self._parse_file_input(photo, PhotoSize, filename=filename), + "has_spoiler": has_spoiler, + "show_caption_above_media": show_caption_above_media, + } + + return await self._send_message( + "sendLivePhoto", + data, + disable_notification=disable_notification, + reply_markup=reply_markup, + protect_content=protect_content, + message_thread_id=message_thread_id, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + reply_parameters=reply_parameters, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + ) + async def delete_message_reaction( self, chat_id: int | str, @@ -12314,7 +12439,6 @@ async def delete_message_reaction( if the reaction were added by a user. actor_chat_id (:obj:`int`, optional): Identifier of the chat whose reaction will be removed, if the reaction were added by a chat. - Returns: :obj:`bool`: On success, :obj:`True` is returned. @@ -12733,6 +12857,8 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """Alias for :meth:`replace_managed_bot_token`""" savePreparedKeyboardButton = save_prepared_keyboard_button """Alias for :meth:`save_prepared_keyboard_button`""" + sendLivePhoto = send_live_photo + """Alias for :meth:`send_live_photo`""" deleteMessageReaction = delete_message_reaction """Alias for :meth:`delete_message_reaction`""" deleteAllMessageReactions = delete_all_message_reactions diff --git a/src/telegram/_chat.py b/src/telegram/_chat.py index c0164f36270..816489e64ef 100644 --- a/src/telegram/_chat.py +++ b/src/telegram/_chat.py @@ -58,6 +58,7 @@ InputChecklist, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, InputMediaPhoto, InputMediaVideo, InputPaidMedia, @@ -1192,7 +1193,7 @@ async def delete_messages( async def send_media_group( self, media: Sequence[ - "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo | InputMediaLivePhoto" # noqa: E501 # pylint: disable=line-too-long ], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, diff --git a/src/telegram/_files/inputmedia.py b/src/telegram/_files/inputmedia.py index 23b6620985a..021183324f1 100644 --- a/src/telegram/_files/inputmedia.py +++ b/src/telegram/_files/inputmedia.py @@ -20,7 +20,7 @@ import datetime as dtm from collections.abc import Sequence -from typing import TYPE_CHECKING, Final, TypeAlias +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._files.animation import Animation @@ -42,8 +42,6 @@ if TYPE_CHECKING: from telegram._utils.types import FileInput -MediaType: TypeAlias = Animation | Audio | Document | PhotoSize | Video - class InputMedia(TelegramObject): """ @@ -123,6 +121,7 @@ class InputPaidMedia(TelegramObject): * :class:`telegram.InputPaidMediaPhoto` * :class:`telegram.InputPaidMediaVideo` + * :class:`telegram.InputPaidMediaLivePhoto` .. seealso:: :wiki:`Working with Files and Media ` @@ -142,6 +141,11 @@ class InputPaidMedia(TelegramObject): """:const:`telegram.constants.InputPaidMediaType.PHOTO`""" VIDEO: Final[str] = constants.InputPaidMediaType.VIDEO """:const:`telegram.constants.InputPaidMediaType.VIDEO`""" + LIVE_PHOTO: Final[str] = constants.InputPaidMediaType.LIVE_PHOTO + """:const:`telegram.constants.InputPaidMediaType.LIVE_PHOTO` + + .. versionadded:: NEXT.VERSION + """ __slots__ = ("media", "type") @@ -300,6 +304,51 @@ def duration(self) -> int | dtm.timedelta | None: return get_timedelta_value(self._duration, attribute="duration") +class InputPaidMediaLivePhoto(InputPaidMedia): + """ + The paid media to send is a live photo. + + .. 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.Video`): Video of the live photo to send. + Pass a ``file_id`` to send a file that exists on the Telegram servers (recommended). + |uploadinputnopath| Sending live photos by a URL is currently unsupported. Lastly you + can pass an existing :class:`telegram.Video` object to send. + photo (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` | \ + :class:`pathlib.Path` | :class:`~telegram.PhotoSize`): Photo of the live photo to send. + Pass a ``file_id`` to send a file that exists on the Telegram servers (recommended). + |uploadinputnopath| Sending live photos by a URL is currently unsupported. + Lastly you can pass an existing :class:`telegram.PhotoSize` object to send. + + Attributes: + type (:obj:`str`): Type of the media, always + :tg-const:`telegram.constants.InputPaidMediaType.LIVE_PHOTO`. + media (:obj:`str` | :class:`telegram.InputFile`): Video of the live photo to send. + |fileinputnopath| + photo (:obj:`str` | :class:`telegram.InputFile`): Photo of the live photo to send. + |fileinputnopath| + """ + + __slots__ = ("photo",) + + def __init__( + self, + media: "FileInput | Video", + photo: "FileInput | PhotoSize", + *, + api_kwargs: JSONDict | None = None, + ): + media = parse_file_input(media, tg_type=Video, attach=True, local_mode=True) + photo = parse_file_input(photo, tg_type=PhotoSize, attach=True, local_mode=True) + super().__init__(type=InputPaidMedia.LIVE_PHOTO, media=media, api_kwargs=api_kwargs) + with self._unfrozen(): + self.photo: str | InputFile = photo + + class InputMediaAnimation(InputMedia): """Represents an animation file (GIF or H.264/MPEG-4 AVC video without sound) to be sent. @@ -907,3 +956,75 @@ def __init__( with self._unfrozen(): self.thumbnail: str | InputFile | None = self._parse_thumbnail_input(thumbnail) self.disable_content_type_detection: bool | None = disable_content_type_detection + + +class InputMediaLivePhoto(InputMedia): + """Represents a live photo 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.Video`): Video of the live photo to send. + Pass a ``file_id`` to send a file that exists on the Telegram servers (recommended). + |uploadinputnopath| Sending live photos by a URL is currently unsupported. Lastly + you can pass an existing :class:`telegram.Video` object to send. + photo (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` \ + | :class:`pathlib.Path` | :class:`~telegram.PhotoSize`): The static photo to send. + Pass a ``file_id`` to send a file that exists on the Telegram servers (recommended). + |uploadinputnopath| Sending live photos by a URL is currently unsupported. Lastly + you can pass an existing :class:`telegram.PhotoSize` object to send. + caption (:obj:`str`, optional): Caption of the live photo to be sent, + 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after + entities parsing. + parse_mode (:obj:`str`, optional): |parse_mode| + caption_entities (Sequence[:class:`telegram.MessageEntity`], optional): |caption_entities| + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| + has_spoiler (:obj:`bool`, optional): Pass :obj:`True`, if the video needs to be covered + with a spoiler animation. + + Attributes: + type (:obj:`str`): :tg-const:`telegram.constants.InputMediaType.LIVE_PHOTO`. + media (:obj:`str` | :class:`telegram.InputFile`): Video of the live photo to send. + photo (:obj:`str` | :class:`telegram.InputFile`): The static photo to send. + caption (:obj:`str`): Optional. Caption of the live photo to be sent, + 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters + after entities parsing. + parse_mode (:obj:`str`): Optional. |parse_mode| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + show_caption_above_media (:obj:`bool`): Optional. |show_cap_above_med| + has_spoiler (:obj:`bool`): Optional. :obj:`True`, if the video is covered with a + spoiler animation. + """ + + __slots__ = ("has_spoiler", "photo", "show_caption_above_media") + + def __init__( + self, + media: "FileInput | Video", + photo: "FileInput | PhotoSize", + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence[MessageEntity] | None = None, + show_caption_above_media: bool | None = None, + has_spoiler: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + media = parse_file_input(media, tg_type=Video, attach=True, local_mode=True) + photo = parse_file_input(photo, tg_type=PhotoSize, attach=True, local_mode=True) + + super().__init__( + InputMediaType.LIVE_PHOTO, + media, + caption, + caption_entities, + parse_mode, + api_kwargs=api_kwargs, + ) + with self._unfrozen(): + self.photo: str | InputFile = photo + self.show_caption_above_media: bool | None = show_caption_above_media + self.has_spoiler: bool | None = has_spoiler diff --git a/src/telegram/_files/livephoto.py b/src/telegram/_files/livephoto.py new file mode 100644 index 00000000000..3700827fa6d --- /dev/null +++ b/src/telegram/_files/livephoto.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object that represents a Telegram LivePhoto.""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from telegram._files._basemedium import _BaseMedium +from telegram._files.photosize import PhotoSize +from telegram._utils.argumentparsing import de_list_optional, parse_sequence_arg, to_timedelta +from telegram._utils.types import JSONDict, TimePeriod + +if TYPE_CHECKING: + import datetime as dtm + + from telegram import Bot + + +class LivePhoto(_BaseMedium): + """ + This object represents a live photo. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`file_unique_id` is equal. + + .. versionadded:: NEXT.VERSION + + Args: + file_id (:obj:`str`): Identifier for the video file which can be used to download or reuse + the file. + file_unique_id (:obj:`str`): Unique identifier for this file, which + is supposed to be the same over time and for different bots. + Can't be used to download or reuse the file. + width (:obj:`int`): Video width as defined by the sender. + height (:obj:`int`): Video height as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the video + in seconds as defined by the sender. + photo (Sequence[:obj:`telegram.PhotoSize`], optional): Available sizes of the corresponding + static photo. + mime_type (:obj:`str`, optional): MIME type of a file as defined by the sender. + file_size (:obj:`int`, optional): File size in bytes. + + Attributes: + file_id (:obj:`str`): Identifier for the video file which can be used to download or reuse + the file. + file_unique_id (:obj:`str`): Unique identifier for this file, which + is supposed to be the same over time and for different bots. + Can't be used to download or reuse the file. + width (:obj:`int`): Video width as defined by the sender. + height (:obj:`int`): Video height as defined by the sender. + duration (:class:`datetime.timedelta`): Duration of the video + in seconds as defined by the sender. + photo (tuple[:obj:`telegram.PhotoSize`]): Optional. Available sizes of the corresponding + static photo. + mime_type (:obj:`str`): Optional. MIME type of a file as defined by the sender. + file_size (:obj:`int`): Optional. File size in bytes. + + """ + + __slots__ = ( + "duration", + "height", + "mime_type", + "photo", + "width", + ) + + def __init__( + self, + file_id: str, + file_unique_id: str, + width: int, + height: int, + duration: TimePeriod, + photo: Sequence[PhotoSize] | None = None, + mime_type: str | None = None, + file_size: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__( + file_id=file_id, + file_unique_id=file_unique_id, + file_size=file_size, + api_kwargs=api_kwargs, + ) + with self._unfrozen(): + # Required + self.width: int = width + self.height: int = height + self.duration: dtm.timedelta = to_timedelta(duration) + # Optional + self.photo: Sequence[PhotoSize] | None = parse_sequence_arg(photo) + self.mime_type: str | None = mime_type + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "LivePhoto": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["photo"] = de_list_optional(data.get("photo"), PhotoSize, bot) + + return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_message.py b/src/telegram/_message.py index b87b8bdae4b..ec68c89aa0f 100644 --- a/src/telegram/_message.py +++ b/src/telegram/_message.py @@ -37,6 +37,7 @@ from telegram._files.audio import Audio from telegram._files.contact import Contact from telegram._files.document import Document +from telegram._files.livephoto import LivePhoto from telegram._files.location import Location from telegram._files.photosize import PhotoSize from telegram._files.sticker import Sticker @@ -112,6 +113,7 @@ InputMedia, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, InputMediaPhoto, InputMediaVideo, InputPaidMedia, @@ -707,6 +709,11 @@ class Message(MaybeInaccessibleMessage): managed_bot_created (:class:`telegram.ManagedBotCreated`, optional): Service message: user created a bot that will be managed by the current bot. + .. versionadded:: NEXT.VERSION + live_photo (:class:`telegram.LivePhoto`, optional): Message is a live photo, information + about the live photo. For backward compatibility, when this field is set, the photo + field will also be set. + .. versionadded:: NEXT.VERSION Attributes: @@ -1139,6 +1146,11 @@ class Message(MaybeInaccessibleMessage): managed_bot_created (:class:`telegram.ManagedBotCreated`): Optional. Service message: user created a bot that will be managed by the current bot. + .. versionadded:: NEXT.VERSION + live_photo (:class:`telegram.LivePhoto`): Optional. Message is a live photo, information + about the live photo. For backward compatibility, when this field is set, the photo + field will also be set. + .. versionadded:: NEXT.VERSION .. |custom_emoji_no_md1_support| replace:: Since custom emoji entities are not supported by @@ -1210,6 +1222,7 @@ class Message(MaybeInaccessibleMessage): "is_topic_message", "left_chat_member", "link_preview_options", + "live_photo", "location", "managed_bot_created", "media_group_id", @@ -1380,6 +1393,7 @@ def __init__( poll_option_deleted: PollOptionDeleted | None = None, reply_to_poll_option_id: str | None = None, managed_bot_created: ManagedBotCreated | None = None, + live_photo: LivePhoto | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -1514,6 +1528,7 @@ def __init__( self.poll_option_deleted: PollOptionDeleted | None = poll_option_deleted self.reply_to_poll_option_id: str | None = reply_to_poll_option_id self.managed_bot_created: ManagedBotCreated | None = managed_bot_created + self.live_photo: LivePhoto | None = live_photo self._effective_attachment = DEFAULT_NONE @@ -1743,6 +1758,7 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Message": data["managed_bot_created"] = de_json_optional( data.get("managed_bot_created"), ManagedBotCreated, bot ) + data["live_photo"] = de_json_optional(data.get("live_photo"), LivePhoto, bot) api_kwargs = {} # This is a deprecated field that TG still returns for backwards compatibility @@ -1774,6 +1790,7 @@ def effective_attachment( | Document | Game | Invoice + | LivePhoto | Location | PassportData | Sequence[PhotoSize] @@ -1798,6 +1815,7 @@ def effective_attachment( * :class:`telegram.Animation` * :class:`telegram.Game` * :class:`telegram.Invoice` + * :class:`telegram.LivePhoto` * :class:`telegram.Location` * :class:`telegram.PassportData` * list[:class:`telegram.PhotoSize`] @@ -2485,7 +2503,7 @@ async def reply_html( async def reply_media_group( self, media: Sequence[ - "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo | InputMediaLivePhoto" # noqa: E501 # pylint: disable=line-too-long ], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, diff --git a/src/telegram/_paidmedia.py b/src/telegram/_paidmedia.py index 67af46710a5..ecf6830cd99 100644 --- a/src/telegram/_paidmedia.py +++ b/src/telegram/_paidmedia.py @@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, Final from telegram import constants +from telegram._files.livephoto import LivePhoto from telegram._files.photosize import PhotoSize from telegram._files.video import Video from telegram._telegramobject import TelegramObject @@ -68,6 +69,8 @@ class PaidMedia(TelegramObject): """:const:`telegram.constants.PaidMediaType.PHOTO`""" VIDEO: Final[str] = constants.PaidMediaType.VIDEO """:const:`telegram.constants.PaidMediaType.VIDEO`""" + LIVE_PHOTO: Final[str] = constants.PaidMediaType.LIVE_PHOTO + """:const:`telegram.constants.PaidMediaType.LIVE_PHOTO`""" def __init__( self, @@ -100,6 +103,7 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PaidMedia": cls.PREVIEW: PaidMediaPreview, cls.PHOTO: PaidMediaPhoto, cls.VIDEO: PaidMediaVideo, + cls.LIVE_PHOTO: PaidMediaLivePhoto, } if cls is PaidMedia and data.get("type") in _class_mapping: @@ -251,6 +255,47 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PaidMediaVideo": return super().de_json(data=data, bot=bot) # type: ignore[return-value] +class PaidMediaLivePhoto(PaidMedia): + """ + The paid media is a live photo. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`live_photo` are equal. + + .. versionadded:: NEXT.VERSION + + Args: + type (:obj:`str`): Type of the paid media, always :tg-const:`telegram.PaidMedia.LIVE_PHOTO` + live_photo (:class:`telegram.LivePhoto`): The photo. + + Attributes: + type (:obj:`str`): Type of the paid media, always :tg-const:`telegram.PaidMedia.LIVE_PHOTO` + live_photo (:class:`telegram.LivePhoto`): The photo. + + """ + + __slots__ = ("live_photo",) + + def __init__( + self, + live_photo: LivePhoto, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=PaidMedia.LIVE_PHOTO, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.live_photo: LivePhoto = live_photo + self._id_attrs = (self.type, self.live_photo) + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PaidMediaLivePhoto": + data = cls._parse_data(data) + + data["live_photo"] = de_json_optional(data.get("live_photo"), LivePhoto, bot) + return super().de_json(data=data, bot=bot) # type: ignore[return-value] + + class PaidMediaInfo(TelegramObject): """ Describes the paid media added to a message. diff --git a/src/telegram/_reply.py b/src/telegram/_reply.py index 6f1edfadeb8..a8e37cafacb 100644 --- a/src/telegram/_reply.py +++ b/src/telegram/_reply.py @@ -28,6 +28,7 @@ from telegram._files.audio import Audio from telegram._files.contact import Contact from telegram._files.document import Document +from telegram._files.livephoto import LivePhoto from telegram._files.location import Location from telegram._files.photosize import PhotoSize from telegram._files.sticker import Sticker @@ -114,6 +115,10 @@ class ExternalReplyInfo(TelegramObject): information about the paid media. .. versionadded:: 21.4 + live_photo (:class:`telegram.LivePhoto`, optional): Message is a live photo, information + about the live photo. + + .. versionadded:: NEXT.VERSION Attributes: origin (:class:`telegram.MessageOrigin`): Origin of the message replied to by the given @@ -166,6 +171,11 @@ class ExternalReplyInfo(TelegramObject): information about the paid media. .. versionadded:: 21.4 + live_photo (:class:`telegram.LivePhoto`): Optional. Message is a live photo, information + about the live photo. + + .. versionadded:: NEXT.VERSION + """ __slots__ = ( @@ -182,6 +192,7 @@ class ExternalReplyInfo(TelegramObject): "has_media_spoiler", "invoice", "link_preview_options", + "live_photo", "location", "message_id", "origin", @@ -223,6 +234,7 @@ def __init__( venue: Venue | None = None, paid_media: PaidMediaInfo | None = None, checklist: Checklist | None = None, + live_photo: LivePhoto | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -253,6 +265,7 @@ def __init__( self.poll: Poll | None = poll self.venue: Venue | None = venue self.paid_media: PaidMediaInfo | None = paid_media + self.live_photo: LivePhoto | None = live_photo self._id_attrs = (self.origin,) @@ -290,6 +303,7 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ExternalReplyInfo data["venue"] = de_json_optional(data.get("venue"), Venue, bot) data["paid_media"] = de_json_optional(data.get("paid_media"), PaidMediaInfo, bot) data["checklist"] = de_json_optional(data.get("checklist"), Checklist, bot) + data["live_photo"] = de_json_optional(data.get("live_photo"), LivePhoto, bot) return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_user.py b/src/telegram/_user.py index a2b53d6fede..15d035a2c33 100644 --- a/src/telegram/_user.py +++ b/src/telegram/_user.py @@ -48,6 +48,7 @@ InlineKeyboardMarkup, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, InputMediaPhoto, InputMediaVideo, InputPollOption, @@ -704,7 +705,7 @@ async def send_photo( async def send_media_group( self, media: Sequence[ - "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo | InputMediaLivePhoto" # noqa: E501 # pylint: disable=line-too-long ], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, diff --git a/src/telegram/constants.py b/src/telegram/constants.py index fb0be0b48d2..af2b4cc5d35 100644 --- a/src/telegram/constants.py +++ b/src/telegram/constants.py @@ -1538,6 +1538,11 @@ class InputMediaType(StringEnum): """:obj:`str`: Type of :class:`telegram.InputMediaPhoto`.""" VIDEO = "video" """:obj:`str`: Type of :class:`telegram.InputMediaVideo`.""" + LIVE_PHOTO = "live_photo" + """:obj:`str`: Type of :class:`telegram.InputMediaLivePhoto`. + + .. versionadded:: NEXT.VERSION + """ class InputPaidMediaType(StringEnum): @@ -1550,9 +1555,14 @@ class InputPaidMediaType(StringEnum): __slots__ = () PHOTO = "photo" - """:obj:`str`: Type of :class:`telegram.InputMediaPhoto`.""" + """:obj:`str`: Type of :class:`telegram.InputPaidMediaPhoto`.""" VIDEO = "video" - """:obj:`str`: Type of :class:`telegram.InputMediaVideo`.""" + """:obj:`str`: Type of :class:`telegram.InputPaidMediaVideo`.""" + LIVE_PHOTO = "live_photo" + """:obj:`str`: Type of :class:`telegram.InputPaidMediaLivePhoto`. + + .. versionadded:: NEXT.VERSION + """ class InputProfilePhotoType(StringEnum): @@ -1963,6 +1973,11 @@ class MessageAttachmentType(StringEnum): """:obj:`str`: Messages with :attr:`telegram.Message.game`.""" INVOICE = "invoice" """:obj:`str`: Messages with :attr:`telegram.Message.invoice`.""" + LIVE_PHOTO = "live_photo" + """:obj:`str`: Messages with :attr:`telegram.Message.live_photo`. + + .. versionadded:: NEXT.VERSION + """ LOCATION = "location" """:obj:`str`: Messages with :attr:`telegram.Message.location`.""" PAID_MEDIA = "paid_media" @@ -2345,6 +2360,11 @@ class MessageType(StringEnum): """:obj:`str`: Messages with :attr:`telegram.Message.invoice`.""" LEFT_CHAT_MEMBER = "left_chat_member" """:obj:`str`: Messages with :attr:`telegram.Message.left_chat_member`.""" + LIVE_PHOTO = "live_photo" + """:obj:`str`: Messages with :attr:`telegram.Message.live_photo`. + + .. versionadded:: NEXT.VERSION + """ LOCATION = "location" """:obj:`str`: Messages with :attr:`telegram.Message.location`.""" MANAGED_BOT_CREATED = "managed_bot_created" @@ -2566,6 +2586,11 @@ class PaidMediaType(StringEnum): """:obj:`str`: The type of :class:`telegram.PaidMediaVideo`.""" PHOTO = "photo" """:obj:`str`: The type of :class:`telegram.PaidMediaPhoto`.""" + LIVE_PHOTO = "live_photo" + """:obj:`str`: The type of :class:`telegram.PaidMediaLivePhoto` + + .. versionadded:: NEXT.VERSION + """ class PollingLimit(IntEnum): diff --git a/src/telegram/ext/_extbot.py b/src/telegram/ext/_extbot.py index b6595fe0e98..3c62c63aa8d 100644 --- a/src/telegram/ext/_extbot.py +++ b/src/telegram/ext/_extbot.py @@ -119,11 +119,13 @@ InlineQueryResult, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, InputMediaPhoto, InputMediaVideo, InputSticker, InputStoryContent, LabeledPrice, + LivePhoto, Location, MessageEntity, PassportElementError, @@ -3046,7 +3048,7 @@ async def send_media_group( self, chat_id: int | str, media: Sequence[ - "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo | InputMediaLivePhoto" # noqa: E501 # pylint: disable=line-too-long ], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, @@ -5595,6 +5597,63 @@ async def save_prepared_keyboard_button( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def send_live_photo( + self, + chat_id: int | str, + live_photo: "FileInput | LivePhoto", + photo: "FileInput | PhotoSize", + business_connection_id: str | None = None, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + has_spoiler: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + allow_paid_broadcast: bool | None = None, + message_effect_id: str | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "ReplyMarkup | None" = None, + *, + filename: str | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> Message: + + return await super().send_live_photo( + chat_id=chat_id, + live_photo=live_photo, + photo=photo, + business_connection_id=business_connection_id, + message_thread_id=message_thread_id, + direct_messages_topic_id=direct_messages_topic_id, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + show_caption_above_media=show_caption_above_media, + has_spoiler=has_spoiler, + disable_notification=disable_notification, + protect_content=protect_content, + allow_paid_broadcast=allow_paid_broadcast, + message_effect_id=message_effect_id, + suggested_post_parameters=suggested_post_parameters, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + filename=filename, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + async def delete_message_reaction( self, chat_id: int | str, @@ -5814,5 +5873,6 @@ async def delete_all_message_reactions( getManagedBotToken = get_managed_bot_token replaceManagedBotToken = replace_managed_bot_token savePreparedKeyboardButton = save_prepared_keyboard_button + sendLivePhoto = send_live_photo deleteMessageReaction = delete_message_reaction deleteAllMessageReactions = delete_all_message_reactions diff --git a/src/telegram/ext/filters.py b/src/telegram/ext/filters.py index d8fa167e7b0..c3dfff2fb24 100644 --- a/src/telegram/ext/filters.py +++ b/src/telegram/ext/filters.py @@ -62,6 +62,7 @@ "IS_AUTOMATIC_FORWARD", "IS_FROM_OFFLINE", "IS_TOPIC_MESSAGE", + "LIVE_PHOTO", "LOCATION", "PAID_MEDIA", "PASSPORT_DATA", @@ -1651,6 +1652,20 @@ def filter(self, message: Message) -> bool: ) +class _LivePhoto(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.live_photo) + + +LIVE_PHOTO = _LivePhoto(name="filters.LIVE_PHOTO") +"""Messages that contain :attr:`telegram.Message.live_photo`. + +.. versionadded:: NEXT.VERSION +""" + + class _Location(MessageFilter): __slots__ = () diff --git a/tests/_files/conftest.py b/tests/_files/conftest.py index 5bff5e23728..9e8e5212e11 100644 --- a/tests/_files/conftest.py +++ b/tests/_files/conftest.py @@ -145,3 +145,19 @@ def video_sticker_file(): def video_sticker(bot, chat_id): with data_file("telegram_video_sticker.webm").open("rb") as f: return bot.send_sticker(chat_id, sticker=f, timeout=50).sticker + + +@pytest.fixture(scope="session") +async def real_live_photo(bot, chat_id): + with ( + data_file("telegram.jpg").open("rb") as photo, + data_file("telegram.mp4").open("rb") as video, + ): + return ( + await bot.send_live_photo( + chat_id, + live_photo=video, + photo=photo, + read_timeout=50, + ) + ).live_photo diff --git a/tests/_files/test_inputmedia.py b/tests/_files/test_inputmedia.py index d43a853ec7d..d9317ac1f3b 100644 --- a/tests/_files/test_inputmedia.py +++ b/tests/_files/test_inputmedia.py @@ -29,8 +29,10 @@ InputMediaAnimation, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, InputMediaPhoto, InputMediaVideo, + InputPaidMediaLivePhoto, InputPaidMediaPhoto, InputPaidMediaVideo, Message, @@ -142,6 +144,27 @@ def input_paid_media_video(class_thumb_file): ) +@pytest.fixture(scope="module") +def input_media_live_photo(): + return InputMediaLivePhoto( + media=InputMediaLivePhotoTestBase.media, + photo=InputMediaLivePhotoTestBase.photo, + caption=InputMediaLivePhotoTestBase.caption, + parse_mode=InputMediaLivePhotoTestBase.parse_mode, + caption_entities=InputMediaLivePhotoTestBase.caption_entities, + show_caption_above_media=InputMediaLivePhotoTestBase.show_caption_above_media, + has_spoiler=InputMediaLivePhotoTestBase.has_spoiler, + ) + + +@pytest.fixture(scope="module") +def input_paid_media_live_photo(): + return InputPaidMediaLivePhoto( + media=InputMediaLivePhotoTestBase.media, + photo=InputMediaLivePhotoTestBase.photo, + ) + + class InputMediaVideoTestBase: type_ = "video" media = "NOTAREALFILEID" @@ -612,13 +635,13 @@ def test_to_dict(self, input_paid_media_photo): assert input_paid_media_photo_dict["media"] == input_paid_media_photo.media def test_with_photo(self, photo): - # fixture found in test_photo + # fixture found in conftest.py input_paid_media_photo = InputPaidMediaPhoto(photo) assert input_paid_media_photo.type == self.type_ assert input_paid_media_photo.media == photo.file_id def test_with_photo_file(self, photo_file): - # fixture found in test_photo + # fixture found in conftest.py input_paid_media_photo = InputPaidMediaPhoto(photo_file) assert input_paid_media_photo.type == self.type_ assert isinstance(input_paid_media_photo.media, InputFile) @@ -628,6 +651,76 @@ def test_with_local_files(self): assert input_paid_media_photo.media == data_file("telegram.jpg").as_uri() +class InputMediaLivePhotoTestBase: + type_ = "live_photo" + media = "NOTAREALFILEID" + photo = "NOTAREALFILEID" + caption = "My Caption" + parse_mode = "Markdown" + caption_entities = [MessageEntity(MessageEntity.BOLD, 0, 2)] + show_caption_above_media = True + has_spoiler = True + + +class TestInputMediaLivePhotoWithoutRequest(InputMediaLivePhotoTestBase): + def test_slot_behaviour(self, input_media_live_photo): + inst = input_media_live_photo + 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_live_photo): + assert input_media_live_photo.type == self.type_ + assert input_media_live_photo.media == self.media + assert input_media_live_photo.photo == self.photo + assert input_media_live_photo.caption == self.caption + assert input_media_live_photo.parse_mode == self.parse_mode + assert input_media_live_photo.caption_entities == tuple(self.caption_entities) + assert input_media_live_photo.show_caption_above_media == self.show_caption_above_media + assert input_media_live_photo.has_spoiler == self.has_spoiler + + def test_caption_entities_always_tuple(self): + input_media_live_photo = InputMediaLivePhoto(self.media, self.photo) + assert input_media_live_photo.caption_entities == () + + def test_to_dict(self, input_media_live_photo): + input_media_live_photo_dict = input_media_live_photo.to_dict() + assert input_media_live_photo_dict["type"] == input_media_live_photo.type + assert input_media_live_photo_dict["media"] == input_media_live_photo.media + assert input_media_live_photo_dict["photo"] == input_media_live_photo.photo + assert input_media_live_photo_dict["caption"] == input_media_live_photo.caption + assert input_media_live_photo_dict["parse_mode"] == input_media_live_photo.parse_mode + assert input_media_live_photo_dict["caption_entities"] == [ + ce.to_dict() for ce in input_media_live_photo.caption_entities + ] + assert ( + input_media_live_photo_dict["show_caption_above_media"] + == input_media_live_photo.show_caption_above_media + ) + assert input_media_live_photo_dict["has_spoiler"] == input_media_live_photo.has_spoiler + + def test_with_photo_and_video(self, video, photo): + # fixtures found in conftest.py + input_media_live_photo = InputMediaLivePhoto(video, photo) + assert input_media_live_photo.type == self.type_ + assert input_media_live_photo.media == video.file_id + assert input_media_live_photo.photo == photo.file_id + + def test_with_photo_and_file(self, video_file, photo_file): + # fixture found in conftest.py + input_media_live_photo = InputMediaLivePhoto(video_file, photo_file) + assert input_media_live_photo.type == self.type_ + assert isinstance(input_media_live_photo.media, InputFile) + assert isinstance(input_media_live_photo.photo, InputFile) + + def test_with_local_files(self): + input_media_live_photo = InputMediaLivePhoto( + media=data_file("telegram.mp4"), photo=data_file("telegram.jpg") + ) + assert input_media_live_photo.media == data_file("telegram.mp4").as_uri() + assert input_media_live_photo.photo == data_file("telegram.jpg").as_uri() + + class TestInputPaidMediaVideoWithoutRequest(InputMediaVideoTestBase): def test_slot_behaviour(self, input_paid_media_video): inst = input_paid_media_video @@ -711,6 +804,46 @@ def test_with_local_files(self): assert input_paid_media_video.cover == data_file("telegram.jpg").as_uri() +class TestInputPaidMediaLivePhotoWithoutRequest(InputMediaLivePhotoTestBase): + def test_slot_behaviour(self, input_paid_media_live_photo): + inst = input_paid_media_live_photo + 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_paid_media_live_photo): + assert input_paid_media_live_photo.type == self.type_ + assert input_paid_media_live_photo.media == self.media + assert input_paid_media_live_photo.photo == self.photo + + def test_to_dict(self, input_paid_media_live_photo): + input_paid_media_live_photo_dict = input_paid_media_live_photo.to_dict() + assert input_paid_media_live_photo_dict["type"] == input_paid_media_live_photo.type + assert input_paid_media_live_photo_dict["media"] == input_paid_media_live_photo.media + assert input_paid_media_live_photo_dict["photo"] == input_paid_media_live_photo.photo + + def test_with_photo(self, video, photo): + # fixtures found in conftest.py + input_paid_media_live_photo = InputPaidMediaLivePhoto(video, photo) + assert input_paid_media_live_photo.type == self.type_ + assert input_paid_media_live_photo.media == video.file_id + assert input_paid_media_live_photo.photo == photo.file_id + + def test_with_photo_file(self, photo_file): + # fixture found in conftest.py + input_paid_media_live_photo = InputPaidMediaLivePhoto(photo_file, photo_file) + assert input_paid_media_live_photo.type == self.type_ + assert isinstance(input_paid_media_live_photo.media, InputFile) + assert isinstance(input_paid_media_live_photo.photo, InputFile) + + def test_with_local_files(self): + input_paid_media_live_photo = InputPaidMediaLivePhoto( + media=data_file("telegram.mp4"), photo=data_file("telegram.jpg") + ) + assert input_paid_media_live_photo.media == data_file("telegram.mp4").as_uri() + assert input_paid_media_live_photo.photo == data_file("telegram.jpg").as_uri() + + @pytest.fixture(scope="module") def media_group(photo, thumb): return [ @@ -1155,7 +1288,9 @@ async def test_send_media_group_default_parse_mode( @pytest.mark.parametrize( "default_bot", [{"parse_mode": ParseMode.HTML}], indirect=True, ids=["HTML-Bot"] ) - @pytest.mark.parametrize("media_type", ["animation", "document", "audio", "photo", "video"]) + @pytest.mark.parametrize( + "media_type", ["animation", "document", "audio", "live_photo", "photo", "video"] + ) async def test_edit_message_media_default_parse_mode( self, chat_id, @@ -1194,6 +1329,8 @@ def build_media(parse_mode, med_type): return InputMediaPhoto(photo, **kwargs) if med_type == "video": return InputMediaVideo(video, **kwargs) + if med_type == "live_photo": + return InputMediaLivePhoto(video, photo, **kwargs) return None message = await default_bot.send_photo(chat_id, photo) diff --git a/tests/_files/test_livephoto.py b/tests/_files/test_livephoto.py new file mode 100644 index 00000000000..a1751d36186 --- /dev/null +++ b/tests/_files/test_livephoto.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object that tests a Telegram LivePhoto.""" + +import asyncio +import datetime as dtm +import os +from pathlib import Path + +import pytest + +from telegram import ( + InputFile, + LivePhoto, + MessageEntity, + PhotoSize, + ReplyParameters, + Voice, +) +from telegram.constants import ParseMode +from telegram.error import BadRequest, TelegramError +from telegram.helpers import escape_markdown +from telegram.request import RequestData +from tests.auxil.build_messages import make_message +from tests.auxil.files import data_file +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def live_photo(): + return LivePhoto( + file_id=LivePhotoTestBase.file_id, + file_unique_id=LivePhotoTestBase.file_unique_id, + width=LivePhotoTestBase.width, + height=LivePhotoTestBase.height, + duration=LivePhotoTestBase.duration, + photo=LivePhotoTestBase.photo, + mime_type=LivePhotoTestBase.mime_type, + file_size=LivePhotoTestBase.file_size, + ) + + +class LivePhotoTestBase: + caption = "LivePhotoTest - *Caption*" + width = 360 + height = 640 + duration = dtm.timedelta(seconds=5) + file_size = 326534 + mime_type = "video/mp4" + photo = (PhotoSize("file_id", "unique_id", 640, 360, file_size=0),) + file_id = "5a3128a4d2a04750b5b58397f3b5e812" + file_unique_id = "adc3145fd2e84d95b64d68eaa22aa33e" + + +class TestLivePhotoWithoutRequest(LivePhotoTestBase): + def test_slot_behaviour(self, live_photo): + for attr in live_photo.__slots__: + assert getattr(live_photo, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(live_photo)) == len(set(mro_slots(live_photo))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "file_id": self.file_id, + "file_unique_id": self.file_unique_id, + "width": self.width, + "height": self.height, + "duration": int(self.duration.total_seconds()), + "mime_type": self.mime_type, + "file_size": self.file_size, + "photo": [photo_size.to_dict() for photo_size in self.photo], + } + json_live_photo = LivePhoto.de_json(json_dict, offline_bot) + assert json_live_photo.api_kwargs == {} + + assert json_live_photo.file_id == self.file_id + assert json_live_photo.file_unique_id == self.file_unique_id + assert json_live_photo.width == self.width + assert json_live_photo.height == self.height + assert json_live_photo.duration == self.duration + assert json_live_photo.mime_type == self.mime_type + assert json_live_photo.file_size == self.file_size + assert json_live_photo.photo == self.photo + + def test_to_dict(self, live_photo): + live_photo_dict = live_photo.to_dict() + + assert isinstance(live_photo_dict, dict) + assert live_photo_dict["file_id"] == live_photo.file_id + assert live_photo_dict["file_unique_id"] == live_photo.file_unique_id + assert live_photo_dict["width"] == live_photo.width + assert live_photo_dict["height"] == live_photo.height + assert live_photo_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(live_photo_dict["duration"], int) + assert live_photo_dict["mime_type"] == live_photo.mime_type + assert live_photo_dict["file_size"] == live_photo.file_size + assert live_photo_dict["photo"] == [p.to_dict() for p in self.photo] + + def test_equality(self, live_photo): + a = LivePhoto( + live_photo.file_id, live_photo.file_unique_id, self.width, self.height, self.duration + ) + b = LivePhoto("", live_photo.file_unique_id, self.width, self.height, self.duration) + c = LivePhoto(live_photo.file_id, live_photo.file_unique_id, 0, 0, 0) + d = LivePhoto("", "", self.width, self.height, self.duration) + e = Voice(live_photo.file_id, live_photo.file_unique_id, self.duration) + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a == c + assert hash(a) == hash(c) + + assert a != d + assert hash(a) != hash(d) + + assert a != e + assert hash(a) != hash(e) + + async def test_send_with_live_photo(self, monkeypatch, offline_bot, chat_id, live_photo): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + data = request_data.parameters + return ( + data["live_photo"] == live_photo.file_id + and data["photo"] == live_photo.photo[0].file_id + ) + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + assert await offline_bot.send_live_photo( + chat_id=chat_id, + live_photo=live_photo, + photo=live_photo.photo[0], + ) + + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_live_photo_default_quote_parse_mode( + self, default_bot, chat_id, live_photo, custom, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["reply_parameters"].get("quote_parse_mode") == ( + custom or default_bot.defaults.quote_parse_mode + ) + return make_message("dummy reply").to_dict() + + kwargs = {"message_id": "1"} + if custom is not None: + kwargs["quote_parse_mode"] = custom + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_live_photo( + chat_id=chat_id, + live_photo=live_photo, + photo=live_photo.photo[0], + reply_parameters=ReplyParameters(**kwargs), + ) + + @pytest.mark.parametrize("local_mode", [True, False]) + async def test_send_live_photo( + self, dummy_message_dict, monkeypatch, offline_bot, chat_id, local_mode + ): + try: + offline_bot._local_mode = local_mode + # For just test that the correct paths are passed as we have no local Bot API set up + test_flag = False + photo = data_file("telegram.jpg") + expected_photo = photo.as_uri() + + live_photo = data_file("telegram.mp4") + expected_live_photo = live_photo.as_uri() + + async def make_assertion(_, data, *args, **kwargs): + nonlocal test_flag + if local_mode: + test_flag = ( + data.get("live_photo") == expected_live_photo + and data.get("photo") == expected_photo + ) + else: + test_flag = isinstance(data.get("live_photo"), InputFile) and isinstance( + data.get("photo"), InputFile + ) + return dummy_message_dict + + monkeypatch.setattr(offline_bot, "_post", make_assertion) + await offline_bot.send_live_photo(chat_id, live_photo=live_photo, photo=photo) + assert test_flag + finally: + offline_bot._local_mode = False + + +class TestLivePhotoWithRequest(LivePhotoTestBase): + async def test_error_send_empty_file(self, bot, chat_id): + with Path(os.devnull).open("rb") as f, pytest.raises(TelegramError): + await bot.send_live_photo(chat_id=chat_id, live_photo=f, photo=f) + + async def test_error_send_empty_file_id(self, bot, chat_id): + with pytest.raises(TelegramError): + await bot.send_live_photo(chat_id=chat_id, live_photo="", photo="") + + async def test_get_and_download(self, bot, real_live_photo, tmp_file): + new_file = await bot.get_file(real_live_photo.file_id) + + assert new_file.file_size == real_live_photo.file_size + assert new_file.file_unique_id == real_live_photo.file_unique_id + assert new_file.file_path.startswith("https://") + + await new_file.download_to_drive(tmp_file) + + assert tmp_file.is_file() + + async def test_send_resend(self, bot, chat_id, real_live_photo, photo_file): + message = await bot.send_live_photo( + chat_id=chat_id, live_photo=real_live_photo.file_id, photo=photo_file + ) + assert message.live_photo == real_live_photo + + async def test_send_all_args(self, bot, chat_id, video_file, live_photo, photo_file): + message = await bot.send_live_photo( + chat_id, + live_photo=video_file, + photo=photo_file, + caption=self.caption, + disable_notification=False, + protect_content=True, + filename="telegram_custom.png", + parse_mode="Markdown", + ) + + assert isinstance(message.live_photo, LivePhoto) + assert isinstance(message.live_photo.file_id, str) + assert message.live_photo.file_id + assert isinstance(message.live_photo.file_unique_id, str) + assert message.live_photo.file_unique_id + assert message.live_photo.photo + assert isinstance(message.live_photo.photo[0], PhotoSize) + assert message.live_photo.mime_type == live_photo.mime_type + assert message.live_photo.file_size == live_photo.file_size + assert message.caption == self.caption.replace("*", "") + assert message.has_protected_content + + @pytest.mark.parametrize("default_bot", [{"protect_content": True}], indirect=True) + async def test_send_live_photo_default_protect_content( + self, + chat_id, + default_bot, + real_live_photo, + ): + tasks = asyncio.gather( + default_bot.send_live_photo( + chat_id, photo=real_live_photo.photo[0], live_photo=real_live_photo + ), + default_bot.send_live_photo( + chat_id, + photo=real_live_photo.photo[0], + live_photo=real_live_photo, + protect_content=False, + ), + ) + protected, unprotected = await tasks + assert protected.has_protected_content + assert not unprotected.has_protected_content + + async def test_send_live_photo_caption_entities(self, bot, chat_id, video_file, photo_file): + test_string = "Italic Bold Code" + entities = [ + MessageEntity(MessageEntity.ITALIC, 0, 6), + MessageEntity(MessageEntity.ITALIC, 7, 4), + MessageEntity(MessageEntity.ITALIC, 12, 4), + ] + message = await bot.send_live_photo( + chat_id, + photo=photo_file, + live_photo=video_file, + caption=test_string, + caption_entities=entities, + ) + + assert message.caption == test_string + assert message.caption_entities == tuple(entities) + + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) + async def test_send_live_photo_default_parse_mode_1( + self, default_bot, chat_id, video_file, photo_file + ): + test_string = "Italic Bold Code" + test_markdown_string = "_Italic_ *Bold* `Code`" + + message = await default_bot.send_live_photo( + chat_id, photo=photo_file, live_photo=video_file, caption=test_markdown_string + ) + assert message.caption_markdown == test_markdown_string + assert message.caption == test_string + + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) + async def test_send_live_photo_default_parse_mode_2( + self, default_bot, chat_id, video_file, photo_file + ): + test_markdown_string = "_Italic_ *Bold* `Code`" + + message = await default_bot.send_live_photo( + chat_id, + photo=photo_file, + live_photo=video_file, + caption=test_markdown_string, + parse_mode=None, + ) + assert message.caption == test_markdown_string + assert message.caption_markdown == escape_markdown(test_markdown_string) + + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) + async def test_send_live_photo_default_parse_mode_3( + self, default_bot, chat_id, video_file, photo_file + ): + test_markdown_string = "_Italic_ *Bold* `Code`" + + message = await default_bot.send_live_photo( + chat_id, + photo=photo_file, + live_photo=video_file, + caption=test_markdown_string, + parse_mode="HTML", + ) + assert message.caption == test_markdown_string + assert message.caption_markdown == escape_markdown(test_markdown_string) + + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"allow_sending_without_reply": True}, None), + ({"allow_sending_without_reply": False}, None), + ({"allow_sending_without_reply": False}, True), + ], + indirect=["default_bot"], + ) + async def test_send_live_photo_default_allow_sending_without_reply( + self, default_bot, chat_id, video_file, photo_file, custom + ): + reply_to_message = await default_bot.send_message(chat_id, "test") + await reply_to_message.delete() + if custom is not None: + message = await default_bot.send_live_photo( + chat_id, + photo=photo_file, + live_photo=video_file, + reply_parameters=ReplyParameters( + message_id=reply_to_message.message_id, allow_sending_without_reply=custom + ), + ) + assert message.reply_to_message is None + elif default_bot.defaults.allow_sending_without_reply: + message = await default_bot.send_live_photo( + chat_id, + photo=photo_file, + live_photo=video_file, + reply_parameters=ReplyParameters(message_id=reply_to_message.message_id), + ) + assert message.reply_to_message is None + else: + with pytest.raises(BadRequest, match="Message to be replied not found"): + await default_bot.send_live_photo( + chat_id, + photo=photo_file, + live_photo=video_file, + reply_parameters=ReplyParameters(message_id=reply_to_message.message_id), + ) diff --git a/tests/ext/test_filters.py b/tests/ext/test_filters.py index e68fb0dbc09..3e216d4b44a 100644 --- a/tests/ext/test_filters.py +++ b/tests/ext/test_filters.py @@ -945,6 +945,11 @@ def test_filters_location(self, update): update.message.location = "test" assert filters.LOCATION.check_update(update) + def test_filters_live_photo(self, update): + assert not filters.LIVE_PHOTO.check_update(update) + update.message.live_photo = "test" + assert filters.LIVE_PHOTO.check_update(update) + def test_filters_venue(self, update): assert not filters.VENUE.check_update(update) update.message.venue = "test" diff --git a/tests/test_message.py b/tests/test_message.py index 700491f2752..14a7fc8b826 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -55,6 +55,7 @@ InputPaidMediaPhoto, Invoice, LinkPreviewOptions, + LivePhoto, Location, ManagedBotCreated, Message, @@ -443,6 +444,7 @@ def message(bot): {"poll_option_deleted": PollOptionDeleted(option_persistent_id="abc", option_text="this")}, {"reply_to_poll_option_id": "3123"}, {"managed_bot_created": ManagedBotCreated(bot=User(6, "ManagedBot", True))}, + {"live_photo": LivePhoto("file_id", "file_unique_id", 12, 12, 5)}, ], ids=[ "reply", @@ -541,6 +543,7 @@ def message(bot): "poll_option_deleted", "reply_to_poll_option_id", "managed_bot_created", + "live_photo", ], ) def message_params(bot, request): @@ -1486,6 +1489,7 @@ def test_effective_attachment(self, message_params): "document", "game", "invoice", + "live_photo", "location", "paid_media", "passport_data", diff --git a/tests/test_paidmedia.py b/tests/test_paidmedia.py index 536fcc1f2ad..b6efb1b01a8 100644 --- a/tests/test_paidmedia.py +++ b/tests/test_paidmedia.py @@ -24,8 +24,10 @@ from telegram import ( Dice, + LivePhoto, PaidMedia, PaidMediaInfo, + PaidMediaLivePhoto, PaidMediaPhoto, PaidMediaPreview, PaidMediaPurchased, @@ -64,6 +66,14 @@ class PaidMediaTestBase: file_unique_id="file_unique_id", ), ) + live_photo = LivePhoto( + file_id="live_photo_file_id", + file_unique_id="live_photo_file_unique_id", + width=640, + height=480, + duration=dtm.timedelta(seconds=60), + photo=photo, + ) class TestPaidMediaWithoutRequest(PaidMediaTestBase): @@ -89,6 +99,7 @@ def test_de_json(self, offline_bot): ("photo", PaidMediaPhoto), ("video", PaidMediaVideo), ("preview", PaidMediaPreview), + ("live_photo", PaidMediaLivePhoto), ], ) def test_de_json_subclass(self, offline_bot, pm_type, subclass): @@ -99,6 +110,7 @@ def test_de_json_subclass(self, offline_bot, pm_type, subclass): "width": self.width, "height": self.height, "duration": int(self.duration.total_seconds()), + "live_photo": self.live_photo.to_dict(), } pm = PaidMedia.de_json(json_dict, offline_bot) @@ -226,6 +238,56 @@ def test_equality(self, paid_media_video): assert hash(a) != hash(d) +@pytest.fixture +def paid_media_live_photo(): + return PaidMediaLivePhoto( + live_photo=TestPaidMediaLivePhotoWithoutRequest.live_photo, + ) + + +class TestPaidMediaLivePhotoWithoutRequest(PaidMediaTestBase): + type = PaidMediaType.LIVE_PHOTO + + def test_slot_behaviour(self, paid_media_live_photo): + inst = paid_media_live_photo + 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_de_json(self, offline_bot): + json_dict = { + "live_photo": self.live_photo.to_dict(), + } + pmlp = PaidMediaLivePhoto.de_json(json_dict, offline_bot) + assert pmlp.live_photo == self.live_photo + assert pmlp.api_kwargs == {} + + def test_to_dict(self, paid_media_live_photo): + assert paid_media_live_photo.to_dict() == { + "type": self.type, + "live_photo": paid_media_live_photo.live_photo.to_dict(), + } + + def test_equality(self, paid_media_live_photo): + a = paid_media_live_photo + b = PaidMediaLivePhoto( + live_photo=deepcopy(self.live_photo), + ) + c = PaidMediaLivePhoto( + live_photo=LivePhoto("test", "test_unique", 640, 480, 60), + ) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + @pytest.fixture def paid_media_preview(): return PaidMediaPreview( diff --git a/tests/test_reply.py b/tests/test_reply.py index 1822fff19f4..fa544845522 100644 --- a/tests/test_reply.py +++ b/tests/test_reply.py @@ -29,6 +29,7 @@ ExternalReplyInfo, Giveaway, LinkPreviewOptions, + LivePhoto, MessageEntity, MessageOriginUser, PaidMediaInfo, @@ -50,6 +51,7 @@ def external_reply_info(): giveaway=ExternalReplyInfoTestBase.giveaway, paid_media=ExternalReplyInfoTestBase.paid_media, checklist=ExternalReplyInfoTestBase.checklist, + live_photo=ExternalReplyInfoTestBase.live_photo, ) @@ -73,6 +75,16 @@ class ExternalReplyInfoTestBase: ChecklistTask(text="Item 2", id=2), ], ) + live_photo = LivePhoto( + file_id="file_id", + file_unique_id="file_unique_id", + width=100, + height=100, + duration=dtm.timedelta(seconds=10), + photo=[], + mime_type="image/jpeg", + file_size=1024, + ) class TestExternalReplyInfoWithoutRequest(ExternalReplyInfoTestBase): @@ -92,6 +104,7 @@ def test_de_json(self, offline_bot): "giveaway": self.giveaway.to_dict(), "paid_media": self.paid_media.to_dict(), "checklist": self.checklist.to_dict(), + "live_photo": self.live_photo.to_dict(), } external_reply_info = ExternalReplyInfo.de_json(json_dict, offline_bot) @@ -104,6 +117,7 @@ def test_de_json(self, offline_bot): assert external_reply_info.giveaway == self.giveaway assert external_reply_info.paid_media == self.paid_media assert external_reply_info.checklist == self.checklist + assert external_reply_info.live_photo == self.live_photo def test_to_dict(self, external_reply_info): ext_reply_info_dict = external_reply_info.to_dict() @@ -116,6 +130,7 @@ def test_to_dict(self, external_reply_info): assert ext_reply_info_dict["giveaway"] == self.giveaway.to_dict() assert ext_reply_info_dict["paid_media"] == self.paid_media.to_dict() assert ext_reply_info_dict["checklist"] == self.checklist.to_dict() + assert ext_reply_info_dict["live_photo"] == self.live_photo.to_dict() def test_equality(self, external_reply_info): a = external_reply_info