diff --git a/discord/abc.py b/discord/abc.py index d699f44702..49c4420306 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 @@ -1567,7 +1567,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] @@ -1613,27 +1613,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( @@ -1642,6 +1622,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, @@ -1656,7 +1640,7 @@ async def send( message_reference=reference, stickers=stickers, components=components, - flags=flags, + flags=flags.value, poll=poll, ) finally: @@ -1675,7 +1659,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..fb0e0908dd 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,59 @@ 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:: + + Just like 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. Currently only for voice messages + duration_secs: Optional[:class:`float`] + The duration of the audio file. Currently only for voice messages + """ + + __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, **kwargs) + self.waveform = waveform + self.duration_secs = duration_secs diff --git a/discord/http.py b/discord/http.py index f42bcdc233..e8f461befd 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 @@ -410,9 +411,36 @@ async def close(self) -> None: # login management async def static_login(self, token: str) -> user.User: + # TODO: Remove This When Testing Is Done + import logging + + async def on_request_start( + session, context, params: aiohttp.TraceRequestStartParams + ): + # breakpoint() + logging.getLogger("aiohttp.client").debug( + f"Starting request <{params}> <{session}> <{context}>" + ) + + async def on_request_chunk_sent( + session, context, params: aiohttp.TraceRequestChunkSentParams + ): + with open("output.txt", "a") as file: + if byte := str(params.chunk).find(r"\x", 100) != -1: + file.write(str(params.chunk)[:byte] + "\n") + else: + file.write(str(params.chunk) + "\n") + # logging.getLogger('aiohttp.client').debug(f'Sent Chunk <{params}>') + + trace_config = aiohttp.TraceConfig() + trace_config.on_request_start.append(on_request_start) + trace_config.on_request_chunk_sent.append(on_request_chunk_sent) + # Necessary to get aiohttp to stop complaining about session creation self.__session = aiohttp.ClientSession( - connector=self.connector, ws_response_class=DiscordClientWebSocketResponse + connector=self.connector, + ws_response_class=DiscordClientWebSocketResponse, + trace_configs=[trace_config], ) old_token = self.token self.token = token @@ -567,13 +595,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 +665,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 e7d7fade3e..5aa849bc7e 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 @@ -915,8 +915,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() @@ -954,6 +953,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 090f7c96f2..1daa44d458 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,6 +527,8 @@ def create_interaction_response( "content_type": "application/octet-stream", } ) + if files and any(isinstance(f, VoiceMessage) for f in files): + payload["flags"] = MessageFlags(is_voice_message=True).value payload["attachments"] = attachments form[0]["value"] = utils._to_json(payload) @@ -658,8 +665,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 +689,7 @@ def handle_message_parameters( files = [file] if files: + voice_message = False for index, file in enumerate(files): multipart_files.append( { @@ -689,17 +699,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