diff --git a/discord/message.py b/discord/message.py index 000747e78..c551138f7 100644 --- a/discord/message.py +++ b/discord/message.py @@ -27,6 +27,8 @@ from __future__ import annotations import asyncio import datetime import re +import io +from os import PathLike from typing import ( Dict, TYPE_CHECKING, @@ -53,7 +55,7 @@ from .errors import HTTPException from .components import _component_factory from .embeds import Embed from .member import Member -from .flags import MessageFlags +from .flags import MessageFlags, AttachmentFlags from .file import File from .utils import escape_mentions, MISSING, deprecated from .http import handle_message_parameters @@ -63,7 +65,6 @@ from .sticker import StickerItem, GuildSticker from .threads import Thread from .channel import PartialMessageable from .poll import Poll -from .attachment import Attachment if TYPE_CHECKING: from typing_extensions import Self @@ -107,6 +108,7 @@ if TYPE_CHECKING: __all__ = ( + 'Attachment', 'Message', 'PartialMessage', 'MessageInteraction', @@ -138,6 +140,298 @@ def convert_emoji_reaction(emoji: Union[EmojiInputType, Reaction]) -> str: raise TypeError(f'emoji argument must be str, Emoji, or Reaction not {emoji.__class__.__name__}.') +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 + """ + + __slots__ = ( + 'id', + 'size', + 'height', + 'width', + 'filename', + 'url', + 'proxy_url', + '_http', + 'content_type', + 'description', + 'ephemeral', + 'duration', + 'waveform', + '_flags', + 'title', + ) + + def __init__(self, *, data: AttachmentPayload, state: ConnectionState): + self.id: int = int(data['id']) + self.size: int = data['size'] + self.height: Optional[int] = data.get('height') + self.width: Optional[int] = data.get('width') + self.filename: str = data['filename'] + self.url: str = data['url'] + self.proxy_url: str = data['proxy_url'] + self._http = state.http + self.content_type: Optional[str] = data.get('content_type') + self.description: Optional[str] = data.get('description') + self.ephemeral: bool = data.get('ephemeral', False) + self.duration: Optional[float] = data.get('duration_secs') + self.title: Optional[str] = data.get('title') + + waveform = data.get('waveform') + self.waveform: Optional[bytes] = utils._base64_to_bytes(waveform) if waveform is not None else None + + self._flags: int = data.get('flags', 0) + + @property + def flags(self) -> AttachmentFlags: + """:class:`AttachmentFlags`: The attachment's flags.""" + return AttachmentFlags._from_value(self._flags) + + def is_spoiler(self) -> bool: + """:class:`bool`: Whether this attachment contains a spoiler.""" + return 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 __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. + + Returns + ------- + :class:`bytes` + The contents of the 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 to_dict(self) -> AttachmentPayload: + result: AttachmentPayload = { + 'filename': self.filename, + 'id': self.id, + 'proxy_url': self.proxy_url, + 'size': self.size, + 'url': self.url, + 'spoiler': self.is_spoiler(), + } + if self.height: + result['height'] = self.height + if self.width: + result['width'] = self.width + if self.content_type: + result['content_type'] = self.content_type + if self.description is not None: + result['description'] = self.description + return result + + class DeletedReferencedMessage: """A special sentinel type given when the resolved message reference points to a deleted message. @@ -238,9 +532,9 @@ class MessageSnapshot: self.components: List[MessageComponentType] = [] for component_data in data.get('components', []): - component = _component_factory(component_data, state) + component = _component_factory(component_data, state) # type: ignore if component is not None: - self.components.append(component) + self.components.append(component) # type: ignore self._state: ConnectionState = state