diff --git a/discord/attachment.py b/discord/attachment.py deleted file mode 100644 index 4b9765b99..000000000 --- a/discord/attachment.py +++ /dev/null @@ -1,421 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2015-present Rapptz - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the 'Software'), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -""" -from __future__ import annotations - -import io -from os import PathLike -from typing import TYPE_CHECKING, Any, Optional, Union - -from .errors import ClientException -from .mixins import Hashable -from .file import File -from .flags import AttachmentFlags -from .enums import MediaLoadingState, try_enum -from . import utils - -if TYPE_CHECKING: - from .types.attachment import ( - AttachmentBase as AttachmentBasePayload, - Attachment as AttachmentPayload, - UnfurledAttachment as UnfurledAttachmentPayload, - ) - - from .http import HTTPClient - from .state import ConnectionState - -MISSING = utils.MISSING - -__all__ = ( - 'Attachment', - 'UnfurledAttachment', -) - - -class Attachment(Hashable): - """Represents an attachment from Discord. - - .. container:: operations - - .. describe:: str(x) - - Returns the URL of the attachment. - - .. describe:: x == y - - Checks if the attachment is equal to another attachment. - - .. describe:: x != y - - Checks if the attachment is not equal to another attachment. - - .. describe:: hash(x) - - Returns the hash of the attachment. - - .. versionchanged:: 1.7 - Attachment can now be casted to :class:`str` and is hashable. - - Attributes - ------------ - id: :class:`int` - The attachment ID. - size: :class:`int` - The attachment size in bytes. - height: Optional[:class:`int`] - The attachment's height, in pixels. Only applicable to images and videos. - width: Optional[:class:`int`] - The attachment's width, in pixels. Only applicable to images and videos. - filename: :class:`str` - The attachment's filename. - url: :class:`str` - The attachment URL. If the message this attachment was attached - to is deleted, then this will 404. - proxy_url: :class:`str` - The proxy URL. This is a cached version of the :attr:`~Attachment.url` in the - case of images. When the message is deleted, this URL might be valid for a few - minutes or not valid at all. - content_type: Optional[:class:`str`] - The attachment's `media type `_ - - .. versionadded:: 1.7 - description: Optional[:class:`str`] - The attachment's description. Only applicable to images. - - .. versionadded:: 2.0 - ephemeral: :class:`bool` - Whether the attachment is ephemeral. - - .. versionadded:: 2.0 - duration: Optional[:class:`float`] - The duration of the audio file in seconds. Returns ``None`` if it's not a voice message. - - .. versionadded:: 2.3 - waveform: Optional[:class:`bytes`] - The waveform (amplitudes) of the audio in bytes. Returns ``None`` if it's not a voice message. - - .. versionadded:: 2.3 - title: Optional[:class:`str`] - The normalised version of the attachment's filename. - - .. versionadded:: 2.5 - spoiler: :class:`bool` - Whether the attachment is a spoiler or not. Unlike :meth:`.is_spoiler`, this uses the API returned - data. - - .. versionadded:: 2.6 - """ - - __slots__ = ( - 'id', - 'size', - 'ephemeral', - 'duration', - 'waveform', - 'title', - 'url', - 'proxy_url', - 'description', - 'filename', - 'spoiler', - 'height', - 'width', - 'content_type', - '_flags', - '_http', - '_state', - ) - - def __init__(self, *, data: AttachmentPayload, state: Optional[ConnectionState]): - self.id: int = int(data['id']) - self.filename: str = data['filename'] - self.size: int = data['size'] - self.ephemeral: bool = data.get('ephemeral', False) - self.duration: Optional[float] = data.get('duration_secs') - self.title: Optional[str] = data.get('title') - self._state: Optional[ConnectionState] = state - self._http: Optional[HTTPClient] = state.http if state else None - self.url: str = data['url'] - self.proxy_url: str = data['proxy_url'] - self.description: Optional[str] = data.get('description') - self.spoiler: bool = data.get('spoiler', False) - self.height: Optional[int] = data.get('height') - self.width: Optional[int] = data.get('width') - self.content_type: Optional[str] = data.get('content_type') - self._flags: int = data.get('flags', 0) - - @property - def flags(self) -> AttachmentFlags: - """:class:`AttachmentFlags`: The attachment's flag value.""" - return AttachmentFlags._from_value(self._flags) - - def __str__(self) -> str: - return self.url or '' - - async def save( - self, - fp: Union[io.BufferedIOBase, PathLike[Any]], - *, - seek_begin: bool = True, - use_cached: bool = False, - ) -> int: - """|coro| - - Saves this attachment into a file-like object. - - Parameters - ---------- - fp: Union[:class:`io.BufferedIOBase`, :class:`os.PathLike`] - The file-like object to save this attachment to or the filename - to use. If a filename is passed then a file is created with that - filename and used instead. - seek_begin: :class:`bool` - Whether to seek to the beginning of the file after saving is - successfully done. - use_cached: :class:`bool` - Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading - the attachment. This will allow attachments to be saved after deletion - more often, compared to the regular URL which is generally deleted right - after the message is deleted. Note that this can still fail to download - deleted attachments if too much time has passed and it does not work - on some types of attachments. - - Raises - -------- - HTTPException - Saving the attachment failed. - NotFound - The attachment was deleted. - - Returns - -------- - :class:`int` - The number of bytes written. - """ - data = await self.read(use_cached=use_cached) - if isinstance(fp, io.BufferedIOBase): - written = fp.write(data) - if seek_begin: - fp.seek(0) - return written - else: - with open(fp, 'wb') as f: - return f.write(data) - - async def read(self, *, use_cached: bool = False) -> bytes: - """|coro| - - Retrieves the content of this attachment as a :class:`bytes` object. - - .. versionadded:: 1.1 - - Parameters - ----------- - use_cached: :class:`bool` - Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading - the attachment. This will allow attachments to be saved after deletion - more often, compared to the regular URL which is generally deleted right - after the message is deleted. Note that this can still fail to download - deleted attachments if too much time has passed and it does not work - on some types of attachments. - - Raises - ------ - HTTPException - Downloading the attachment failed. - Forbidden - You do not have permissions to access this attachment - NotFound - The attachment was deleted. - ClientException - Cannot read a stateless attachment. - - Returns - ------- - :class:`bytes` - The contents of the attachment. - """ - if not self._http: - raise ClientException( - 'Cannot read a stateless attachment' - ) - - url = self.proxy_url if use_cached else self.url - data = await self._http.get_from_cdn(url) - return data - - async def to_file( - self, - *, - filename: Optional[str] = MISSING, - description: Optional[str] = MISSING, - use_cached: bool = False, - spoiler: bool = False, - ) -> File: - """|coro| - - Converts the attachment into a :class:`File` suitable for sending via - :meth:`abc.Messageable.send`. - - .. versionadded:: 1.3 - - Parameters - ----------- - filename: Optional[:class:`str`] - The filename to use for the file. If not specified then the filename - of the attachment is used instead. - - .. versionadded:: 2.0 - description: Optional[:class:`str`] - The description to use for the file. If not specified then the - description of the attachment is used instead. - - .. versionadded:: 2.0 - use_cached: :class:`bool` - Whether to use :attr:`proxy_url` rather than :attr:`url` when downloading - the attachment. This will allow attachments to be saved after deletion - more often, compared to the regular URL which is generally deleted right - after the message is deleted. Note that this can still fail to download - deleted attachments if too much time has passed and it does not work - on some types of attachments. - - .. versionadded:: 1.4 - spoiler: :class:`bool` - Whether the file is a spoiler. - - .. versionadded:: 1.4 - - Raises - ------ - HTTPException - Downloading the attachment failed. - Forbidden - You do not have permissions to access this attachment - NotFound - The attachment was deleted. - - Returns - ------- - :class:`File` - The attachment as a file suitable for sending. - """ - - data = await self.read(use_cached=use_cached) - file_filename = filename if filename is not MISSING else self.filename - file_description = ( - description if description is not MISSING else self.description - ) - return File( - io.BytesIO(data), - filename=file_filename, - description=file_description, - spoiler=spoiler, - ) - - def is_spoiler(self) -> bool: - """:class:`bool`: Whether this attachment contains a spoiler.""" - return self.spoiler or self.filename.startswith('SPOILER_') - - def is_voice_message(self) -> bool: - """:class:`bool`: Whether this attachment is a voice message.""" - return self.duration is not None and 'voice-message' in self.url - - def __repr__(self) -> str: - return f'' - - def to_dict(self) -> AttachmentPayload: - base: AttachmentPayload = { - 'url': self.url, - 'proxy_url': self.proxy_url, - 'spoiler': self.spoiler, - 'id': self.id, - 'filename': self.filename, - 'size': self.size, - } - - if self.width: - base['width'] = self.width - if self.height: - base['height'] = self.height - if self.description: - base['description'] = self.description - - return base - - -class UnfurledAttachment(Attachment): - """Represents an unfurled attachment item from a :class:`Component`. - - .. versionadded:: 2.6 - - .. container:: operations - - .. describe:: str(x) - - Returns the URL of the attachment. - - .. describe:: x == y - - Checks if the unfurled attachment is equal to another unfurled attachment. - - .. describe:: x != y - - Checks if the unfurled attachment is not equal to another unfurled attachment. - - Attributes - ---------- - height: Optional[:class:`int`] - The attachment's height, in pixels. Only applicable to images and videos. - width: Optional[:class:`int`] - The attachment's width, in pixels. Only applicable to images and videos. - url: :class:`str` - The attachment URL. If the message this attachment was attached - to is deleted, then this will 404. - proxy_url: :class:`str` - The proxy URL. This is a cached version of the :attr:`~Attachment.url` in the - case of images. When the message is deleted, this URL might be valid for a few - minutes or not valid at all. - content_type: Optional[:class:`str`] - The attachment's `media type `_ - description: Optional[:class:`str`] - The attachment's description. Only applicable to images. - spoiler: :class:`bool` - Whether the attachment is a spoiler or not. Unlike :meth:`.is_spoiler`, this uses the API returned - data. - loading_state: :class:`MediaLoadingState` - The cache state of this unfurled attachment. - """ - - __slots__ = ( - 'loading_state', - ) - - def __init__(self, data: UnfurledAttachmentPayload, state: Optional[ConnectionState]) -> None: - self.loading_state: MediaLoadingState = try_enum(MediaLoadingState, data.get('loading_state', 0)) - super().__init__(data={'id': 0, 'filename': '', 'size': 0, **data}, state=state) # type: ignore - - def __repr__(self) -> str: - return f'' - - def to_object_dict(self): - return {'url': self.url} diff --git a/discord/components.py b/discord/components.py index 4e0196f7d..7330b82e9 100644 --- a/discord/components.py +++ b/discord/components.py @@ -34,7 +34,7 @@ from typing import ( Union, ) -from .attachment import UnfurledAttachment +from .asset import AssetMixin from .enums import ( try_enum, ComponentType, @@ -43,7 +43,9 @@ from .enums import ( ChannelType, SelectDefaultValueType, SeparatorSize, + MediaItemLoadingState, ) +from .flags import AttachmentFlags from .colour import Colour from .utils import get_slots, MISSING from .partial_emoji import PartialEmoji, _EmojiTag @@ -68,6 +70,7 @@ if TYPE_CHECKING: MediaGalleryItem as MediaGalleryItemPayload, ThumbnailComponent as ThumbnailComponentPayload, ContainerComponent as ContainerComponentPayload, + UnfurledMediaItem as UnfurledMediaItemPayload, ) from .emoji import Emoji @@ -773,7 +776,7 @@ class ThumbnailComponent(Component): data: ThumbnailComponentPayload, state: Optional[ConnectionState], ) -> None: - self.media: UnfurledAttachment = UnfurledAttachment(data['media'], state) + self.media: UnfurledMediaItem = UnfurledMediaItem._from_data(data['media'], state) self.description: Optional[str] = data.get('description') self.spoiler: bool = data.get('spoiler', False) @@ -817,15 +820,96 @@ class TextDisplay(Component): } +class UnfurledMediaItem(AssetMixin): + """Represents an unfurled media item that can be used on + :class:`MediaGalleryItem`s. + + Unlike :class:`UnfurledAttachment` this represents a media item + not yet stored on Discord and thus it does not have any data. + + Parameters + ---------- + url: :class:`str` + The URL of this media item. + + Attributes + ---------- + proxy_url: Optional[:class:`str`] + The proxy URL. This is a cached version of the :attr:`~UnfurledMediaItem.url` in the + case of images. When the message is deleted, this URL might be valid for a few minutes + or not valid at all. + height: Optional[:class:`int`] + The media item's height, in pixels. Only applicable to images and videos. + width: Optional[:class:`int`] + The media item's width, in pixels. Only applicable to images and videos. + content_type: Optional[:class:`str`] + The media item's `media type `_ + placeholder: Optional[:class:`str`] + The media item's placeholder. + loading_state: Optional[:class:`MediaItemLoadingState`] + The loading state of this media item. + """ + + __slots__ = ( + 'url', + 'proxy_url', + 'height', + 'width', + 'content_type', + '_flags', + 'placeholder', + 'loading_state', + '_state', + ) + + def __init__(self, url: str) -> None: + self.url: str = url + + self.proxy_url: Optional[str] = None + self.height: Optional[int] = None + self.width: Optional[int] = None + self.content_type: Optional[str] = None + self._flags: int = 0 + self.placeholder: Optional[str] = None + self.loading_state: Optional[MediaItemLoadingState] = None + self._state: Optional[ConnectionState] = None + + @property + def flags(self) -> AttachmentFlags: + """:class:`AttachmentFlags`: This media item's flags.""" + return AttachmentFlags._from_value(self._flags) + + @classmethod + def _from_data(cls, data: UnfurledMediaItemPayload, state: Optional[ConnectionState]): + self = cls(data['url']) + self._update(data, state) + return self + + def _update(self, data: UnfurledMediaItemPayload, state: Optional[ConnectionState]) -> None: + self.proxy_url = data['proxy_url'] + self.height = data.get('height') + self.width = data.get('width') + self.content_type = data.get('content_type') + self._flags = data.get('flags', 0) + self.placeholder = data.get('placeholder') + self.loading_state = try_enum(MediaItemLoadingState, data['loading_state']) + self._state = state + + def to_dict(self): + return { + 'url': self.url, + } + + class MediaGalleryItem: """Represents a :class:`MediaGalleryComponent` media item. Parameters ---------- - url: :class:`str` - The url of the media item. This can be a local file uploaded - as an attachment in the message, that can be accessed using - the ``attachment://file-name.extension`` format. + media: Union[:class:`str`, :class:`UnfurledMediaItem`] + The media item data. This can be a string representing a local + file uploaded as an attachment in the message, that can be accessed + using the ``attachment://file-name.extension`` format. description: Optional[:class:`str`] The description to show within this item. spoiler: :class:`bool` @@ -833,7 +917,7 @@ class MediaGalleryItem: """ __slots__ = ( - 'url', + 'media', 'description', 'spoiler', '_state', @@ -841,12 +925,12 @@ class MediaGalleryItem: def __init__( self, - url: str, + media: Union[str, UnfurledMediaItem], *, description: Optional[str] = None, spoiler: bool = False, ) -> None: - self.url: str = url + self.media: UnfurledMediaItem = UnfurledMediaItem(media) if isinstance(media, str) else media self.description: Optional[str] = description self.spoiler: bool = spoiler self._state: Optional[ConnectionState] = None @@ -857,7 +941,7 @@ class MediaGalleryItem: ) -> MediaGalleryItem: media = data['media'] self = cls( - url=media['url'], + media=media['url'], description=data.get('description'), spoiler=data.get('spoiler', False), ) @@ -873,8 +957,8 @@ class MediaGalleryItem: return [cls._from_data(item, state) for item in items] def to_dict(self) -> MediaGalleryItemPayload: - return { # type: ignore - 'media': {'url': self.url}, + return { + 'media': self.media.to_dict(), # type: ignore 'description': self.description, 'spoiler': self.spoiler, } @@ -927,9 +1011,7 @@ class FileComponent(Component): ) def __init__(self, data: FileComponentPayload, state: Optional[ConnectionState]) -> None: - self.media: UnfurledAttachment = UnfurledAttachment( - data['file'], state, - ) + self.media: UnfurledMediaItem = UnfurledMediaItem._from_data(data['file'], state) self.spoiler: bool = data.get('spoiler', False) @property @@ -937,8 +1019,8 @@ class FileComponent(Component): return ComponentType.file def to_dict(self) -> FileComponentPayload: - return { # type: ignore - 'file': {'url': self.url}, + return { + 'file': self.media.to_dict(), # type: ignore 'spoiler': self.spoiler, 'type': self.type.value, } diff --git a/discord/enums.py b/discord/enums.py index 025f0bf14..49684935f 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -78,7 +78,7 @@ __all__ = ( 'SubscriptionStatus', 'MessageReferenceType', 'SeparatorSize', - 'MediaLoadingState', + 'MediaItemLoadingState', ) @@ -877,7 +877,7 @@ class SeparatorSize(Enum): large = 2 -class MediaLoadingState(Enum): +class MediaItemLoadingState(Enum): unknown = 0 loading = 1 loaded = 2 diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index a984a1892..67e380f65 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -23,10 +23,11 @@ DEALINGS IN THE SOFTWARE. """ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, TypeVar +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, TypeVar, Union from .item import Item from ..enums import ComponentType +from ..components import UnfurledMediaItem if TYPE_CHECKING: from typing_extensions import Self @@ -47,9 +48,9 @@ class Thumbnail(Item[V]): Parameters ---------- - url: :class:`str` - The URL of the thumbnail. This can only point to a local attachment uploaded - within this item. URLs must match the ``attachment://file-name.extension`` + media: Union[:class:`str`, :class:`UnfurledMediaItem`] + The media of the thumbnail. This can be a string that points to a local + attachment uploaded within this item. URLs must match the ``attachment://file-name.extension`` structure. description: Optional[:class:`str`] The description of this thumbnail. Defaults to ``None``. @@ -57,11 +58,13 @@ class Thumbnail(Item[V]): Whether to flag this thumbnail as a spoiler. Defaults to ``False``. """ - def __init__(self, url: str, *, description: Optional[str] = None, spoiler: bool = False) -> None: - self.url: str = url + def __init__(self, media: Union[str, UnfurledMediaItem], *, description: Optional[str] = None, spoiler: bool = False) -> None: + self.media: UnfurledMediaItem = UnfurledMediaItem(media) if isinstance(media, str) else media self.description: Optional[str] = description self.spoiler: bool = spoiler + self._underlying = ThumbnailComponent._raw_construct() + @property def type(self) -> Literal[ComponentType.thumbnail]: return ComponentType.thumbnail @@ -73,14 +76,14 @@ class Thumbnail(Item[V]): return { 'type': self.type.value, 'spoiler': self.spoiler, - 'media': {'url': self.url}, + 'media': self.media.to_dict(), 'description': self.description, } @classmethod def from_component(cls, component: ThumbnailComponent) -> Self: return cls( - url=component.media.url, + media=component.media.url, description=component.description, spoiler=component.spoiler, ) diff --git a/discord/ui/view.py b/discord/ui/view.py index 4afcd9fad..c2cef2248 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -255,6 +255,9 @@ class View: # instead of grouping by row we will sort it so it is added # in order and should work as the original implementation + # this will append directly the v2 Components into the list + # and will add to an action row the loose items, such as + # buttons and selects for child in sorted(self._children, key=key): if child._is_v2(): components.append(child.to_component_dict())