diff --git a/CHANGELOG.md b/CHANGELOG.md index dd27c65485..f32a8aab78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ These changes are available on the `master` branch, but have not yet been releas ([#2620](https://github.com/Pycord-Development/pycord/pull/2620)) - Added helper methods to determine the authorizing party of an `Interaction`. ([#2659](https://github.com/Pycord-Development/pycord/pull/2659)) +- Added `VoiceMessage` subclass of `File` to allow voice messages to be sent. + ([#2579](https://github.com/Pycord-Development/pycord/pull/2579)) ### Fixed diff --git a/discord/abc.py b/discord/abc.py index 7e9d462b89..5f83223bc2 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -45,7 +45,7 @@ from .context_managers import Typing from .enums import ChannelType from .errors import ClientException, InvalidArgument -from .file import File +from .file import File, VoiceMessage from .flags import MessageFlags from .invite import Invite from .iterators import HistoryIterator @@ -1569,7 +1569,7 @@ async def send( flags = MessageFlags( suppress_embeds=bool(suppress), suppress_notifications=bool(silent), - ).value + ) if stickers is not None: stickers = [sticker.id for sticker in stickers] @@ -1615,27 +1615,7 @@ async def send( if file is not None: if not isinstance(file, File): raise InvalidArgument("file parameter must be File") - - try: - data = await state.http.send_files( - channel.id, - files=[file], - allowed_mentions=allowed_mentions, - content=content, - tts=tts, - embed=embed, - embeds=embeds, - nonce=nonce, - enforce_nonce=enforce_nonce, - message_reference=reference, - stickers=stickers, - components=components, - flags=flags, - poll=poll, - ) - finally: - file.close() - + files = [file] elif files is not None: if len(files) > 10: raise InvalidArgument( @@ -1644,6 +1624,10 @@ async def send( elif not all(isinstance(file, File) for file in files): raise InvalidArgument("files parameter must be a list of File") + if files is not None: + flags = flags + MessageFlags( + is_voice_message=any(isinstance(f, VoiceMessage) for f in files) + ) try: data = await state.http.send_files( channel.id, @@ -1658,7 +1642,7 @@ async def send( message_reference=reference, stickers=stickers, components=components, - flags=flags, + flags=flags.value, poll=poll, ) finally: @@ -1677,7 +1661,7 @@ async def send( message_reference=reference, stickers=stickers, components=components, - flags=flags, + flags=flags.value, poll=poll, ) diff --git a/discord/file.py b/discord/file.py index cb1a766bc9..f1f99ddc11 100644 --- a/discord/file.py +++ b/discord/file.py @@ -29,7 +29,10 @@ import os from typing import TYPE_CHECKING -__all__ = ("File",) +__all__ = ( + "File", + "VoiceMessage", +) class File: @@ -89,6 +92,7 @@ def __init__( description: str | None = None, spoiler: bool = False, ): + if isinstance(fp, io.IOBase): if not (fp.seekable() and fp.readable()): raise ValueError(f"File buffer {fp!r} must be seekable and readable") @@ -143,3 +147,60 @@ def close(self) -> None: self.fp.close = self._closer if self._owner: self._closer() + + +class VoiceMessage(File): + """A special case of the File class that represents a voice message. + + .. versionadded:: 2.7 + + .. note:: + + Similar to File objects, VoiceMessage objects are single use and are not meant to be reused in + multiple requests. + + Attributes + ---------- + fp: Union[:class:`os.PathLike`, :class:`io.BufferedIOBase`] + A audio file-like object opened in binary mode and read mode + or a filename representing a file in the hard drive to + open. + + .. note:: + + If the file-like object passed is opened via ``open`` then the + modes 'rb' should be used. + + To pass binary data, consider usage of ``io.BytesIO``. + + filename: Optional[:class:`str`] + The filename to display when uploading to Discord. + If this is not given then it defaults to ``fp.name`` or if ``fp`` is + a string then the ``filename`` will default to the string given. + description: Optional[:class:`str`] + The description of a file, used by Discord to display alternative text on images. + spoiler: :class:`bool` + Whether the attachment is a spoiler. + waveform: Optional[:class:`str`] + The base64 encoded bytearray representing a sampled waveform. + duration_secs: Optional[:class:`float`] + The duration of the voice message. + """ + + __slots__ = ( + "waveform", + "duration_secs", + ) + + def __init__( + self, + fp: str | bytes | os.PathLike | io.BufferedIOBase, + filename: str | None = None, + *, + waveform: str = "", + duration_secs: float = 0.0, + **kwargs, + ): + super().__init__(fp, filename, **kwargs) + self.waveform = waveform + self.duration_secs = duration_secs diff --git a/discord/http.py b/discord/http.py index 464710daac..cfee1af712 100644 --- a/discord/http.py +++ b/discord/http.py @@ -44,6 +44,7 @@ LoginFailure, NotFound, ) +from .file import VoiceMessage from .gateway import DiscordClientWebSocketResponse from .utils import MISSING, warn_deprecated @@ -567,13 +568,17 @@ def send_multipart_helper( attachments = [] form.append({"name": "payload_json"}) for index, file in enumerate(files): - attachments.append( - { - "id": index, - "filename": file.filename, - "description": file.description, - } - ) + attachment_info = { + "id": index, + "filename": file.filename, + "description": file.description, + } + if isinstance(file, VoiceMessage): + attachment_info.update( + waveform=file.waveform, + duration_secs=file.duration_secs, + ) + attachments.append(attachment_info) form.append( { "name": f"files[{index}]", @@ -633,13 +638,17 @@ def edit_multipart_helper( attachments = [] form.append({"name": "payload_json"}) for index, file in enumerate(files): - attachments.append( - { - "id": index, - "filename": file.filename, - "description": file.description, - } - ) + attachment_info = { + "id": index, + "filename": file.filename, + "description": file.description, + } + if isinstance(file, VoiceMessage): + attachment_info.update( + waveform=file.waveform, + duration_secs=file.duration_secs, + ) + attachments.append(attachment_info) form.append( { "name": f"files[{index}]", diff --git a/discord/interactions.py b/discord/interactions.py index e9b9c16eec..2bcf430f87 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -37,7 +37,7 @@ try_enum, ) from .errors import ClientException, InteractionResponded, InvalidArgument -from .file import File +from .file import File, VoiceMessage from .flags import MessageFlags from .guild import Guild from .member import Member @@ -957,8 +957,7 @@ async def send_message( if content is not None: payload["content"] = str(content) - if ephemeral: - payload["flags"] = 64 + flags = MessageFlags(ephemeral=ephemeral) if view is not None: payload["components"] = view.to_components() @@ -996,6 +995,11 @@ async def send_message( elif not all(isinstance(file, File) for file in files): raise InvalidArgument("files parameter must be a list of File") + if any(isinstance(file, VoiceMessage) for file in files): + flags = flags + MessageFlags(is_voice_message=True) + + payload["flags"] = flags.value + parent = self._parent adapter = async_context.get() http = parent._state.http diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 23b6386a7d..4fcc21bcb7 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -47,6 +47,7 @@ InvalidArgument, NotFound, ) +from ..file import VoiceMessage from ..flags import MessageFlags from ..http import Route from ..message import Attachment, Message @@ -507,13 +508,17 @@ def create_interaction_response( attachments = [] files = files or [] for index, file in enumerate(files): - attachments.append( - { - "id": index, - "filename": file.filename, - "description": file.description, - } - ) + attachment_info = { + "id": index, + "filename": file.filename, + "description": file.description, + } + if isinstance(file, VoiceMessage): + attachment_info.update( + waveform=file.waveform, + duration_secs=file.duration_secs, + ) + attachments.append(attachment_info) form.append( { "name": f"files[{index}]", @@ -522,7 +527,7 @@ def create_interaction_response( "content_type": "application/octet-stream", } ) - payload["attachments"] = attachments + payload["data"]["attachments"] = attachments form[0]["value"] = utils._to_json(payload) route = Route( @@ -658,8 +663,10 @@ def handle_message_parameters( if username: payload["username"] = username - flags = MessageFlags(suppress_embeds=suppress, ephemeral=ephemeral) - payload["flags"] = flags.value + flags = MessageFlags( + suppress_embeds=suppress, + ephemeral=ephemeral, + ) if applied_tags is not MISSING: payload["applied_tags"] = applied_tags @@ -680,6 +687,7 @@ def handle_message_parameters( files = [file] if files: + voice_message = False for index, file in enumerate(files): multipart_files.append( { @@ -689,17 +697,26 @@ def handle_message_parameters( "content_type": "application/octet-stream", } ) - _attachments.append( - { - "id": index, - "filename": file.filename, - "description": file.description, - } - ) + attachment_info = { + "id": index, + "filename": file.filename, + "description": file.description, + } + if isinstance(file, VoiceMessage): + voice_message = True + attachment_info.update( + waveform=file.waveform, + duration_secs=file.duration_secs, + ) + _attachments.append(attachment_info) + if voice_message: + flags = flags + MessageFlags(is_voice_message=True) if _attachments: payload["attachments"] = _attachments + payload["flags"] = flags.value + if multipart_files: multipart.append({"name": "payload_json", "value": utils._to_json(payload)}) payload = None