diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 65265129c2..63f0586211 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: # - --remove-duplicate-keys # - --remove-unused-variables - repo: https://github.com/asottile/pyupgrade - rev: v3.19.0 + rev: v3.19.1 hooks: - id: pyupgrade exclude: \.(po|pot|yml|yaml)$ diff --git a/CHANGELOG.md b/CHANGELOG.md index 88666beceb..af98289db1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,12 +32,16 @@ These changes are available on the `master` branch, but have not yet been releas ([#2590](https://github.com/Pycord-Development/pycord/pull/2590)) - Added missing `with_counts` parameter to `fetch_guilds` method. ([#2615](https://github.com/Pycord-Development/pycord/pull/2615)) -- Added missing permissions: `Permissions.use_soundboard`, - `Permissions.use_external_sounds` and +- Added the following missing permissions: `Permissions.use_soundboard`, + `Permissions.use_external_sounds`, and `Permissions.view_creator_monetization_analytics`. ([#2620](https://github.com/Pycord-Development/pycord/pull/2620)) - Added `Message._raw_data` attribute. ([#2670](https://github.com/Pycord-Development/pycord/pull/2670)) +- 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 @@ -56,7 +60,7 @@ These changes are available on the `master` branch, but have not yet been releas ([#2595](https://github.com/Pycord-Development/pycord/pull/2595)) - Fixed `BucketType.category` cooldown commands not functioning correctly in private channels. ([#2603](https://github.com/Pycord-Development/pycord/pull/2603)) -- Fixed `SlashCommand`'s `ctx` parameter couldn't be `Union` type. +- Fixed `ctx` parameter of a `SlashCommand` not being `Union` type. ([#2611](https://github.com/Pycord-Development/pycord/pull/2611)) - Fixed `TypeError` when passing `skus` parameter in `Client.entitlements()`. ([#2627](https://github.com/Pycord-Development/pycord/issues/2627)) @@ -66,6 +70,12 @@ These changes are available on the `master` branch, but have not yet been releas apps. ([#2650](https://github.com/Pycord-Development/pycord/pull/2650)) - Fixed type annotations of cached properties. ([#2635](https://github.com/Pycord-Development/pycord/issues/2635)) +- Fixed an error when responding non-ephemerally with a `Paginator` to an ephemerally + deferred interaction. + ([#2661](https://github.com/Pycord-Development/pycord/pull/2661)) +- Fixed attachment metadata being set incorrectly in interaction responses causing the + metadata to be ignored by Discord. + ([#2679](https://github.com/Pycord-Development/pycord/pull/2679)) ### Changed @@ -79,6 +89,8 @@ These changes are available on the `master` branch, but have not yet been releas - Replaced audioop (deprecated module) implementation of `PCMVolumeTransformer.read` method with a pure Python equivalent. ([#2176](https://github.com/Pycord-Development/pycord/pull/2176)) +- Updated `Guild.filesize_limit` to 10 MB instead of 25 MB following Discord's API + changes. ([#2671](https://github.com/Pycord-Development/pycord/pull/2671)) ### Deprecated @@ -392,7 +404,7 @@ These changes are available on the `master` branch, but have not yet been releas ([#2075](https://github.com/Pycord-Development/pycord/pull/2075)) - Fixed `before_invoke` not being run for `SlashCommandGroup`. ([#2091](https://github.com/Pycord-Development/pycord/pull/2091)) -- Fixed `AttributeError` when accessing a `Select` object's values when it hasn't been +- Fixed `AttributeError` when accessing a `Select` object's values when it has not been interacted with. ([#2104](https://github.com/Pycord-Development/pycord/pull/2104)) - Fixed `before_invoke` being run twice for slash subcommands. ([#2139](https://github.com/Pycord-Development/pycord/pull/2139)) @@ -423,7 +435,7 @@ These changes are available on the `master` branch, but have not yet been releas ([#2196](https://github.com/Pycord-Development/pycord/pull/2196)) - Fixed `AttributeError` when running permission checks without the `bot` scope. ([#2113](https://github.com/Pycord-Development/pycord/issues/2113)) -- Fixed `Option` not working on bridge commands because `ext.commands.Command` doesn't +- Fixed `Option` not working on bridge commands because `ext.commands.Command` does not recognize them. ([#2256](https://github.com/Pycord-Development/pycord/pull/2256)) - Fixed offset-aware tasks causing `TypeError` when being prepared. ([#2271](https://github.com/Pycord-Development/pycord/pull/2271)) @@ -523,7 +535,7 @@ These changes are available on the `master` branch, but have not yet been releas ### Fixed - Fixed bugs in `Page.update_files` where file objects stored in memory were causing an - `AttributeError`, and `io.BytesIO` files didn't send properly more than once. + `AttributeError`, and `io.BytesIO` files did not send properly more than once. ([#1869](https://github.com/Pycord-Development/pycord/pull/1869) & [#1881](https://github.com/Pycord-Development/pycord/pull/1881)) - Fixed bridge groups missing the `parent` attribute. @@ -878,9 +890,9 @@ These changes are available on the `master` branch, but have not yet been releas ([#1453](https://github.com/Pycord-Development/pycord/pull/1453)) - Update `thread.members` on `thread.fetch_members`. ([#1464](https://github.com/Pycord-Development/pycord/pull/1464)) -- Fix the error when Discord doesn't send the `app_permissions` data in `Interaction`. +- Fix the error when Discord does not send the `app_permissions` data in `Interaction`. ([#1467](https://github.com/Pycord-Development/pycord/pull/1467)) -- Fix AttributeError when voice client `play()` function isn't completed yet. +- Fix AttributeError when voice client `play()` function is not completed yet. ([#1360](https://github.com/Pycord-Development/pycord/pull/1360)) ## [2.0.0-rc.1] - 2022-05-17 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/commands/context.py b/discord/commands/context.py index 27c3b0acba..532d8abe2a 100644 --- a/discord/commands/context.py +++ b/discord/commands/context.py @@ -345,6 +345,40 @@ def cog(self) -> Cog | None: return self.command.cog + def is_guild_authorised(self) -> bool: + """:class:`bool`: Checks if the invoked command is guild-installed. + This is a shortcut for :meth:`Interaction.is_guild_authorised`. + + There is an alias for this called :meth:`.is_guild_authorized`. + + .. versionadded:: 2.7 + """ + return self.interaction.is_guild_authorised() + + def is_user_authorised(self) -> bool: + """:class:`bool`: Checks if the invoked command is user-installed. + This is a shortcut for :meth:`Interaction.is_user_authorised`. + + There is an alias for this called :meth:`.is_user_authorized`. + + .. versionadded:: 2.7 + """ + return self.interaction.is_user_authorised() + + def is_guild_authorized(self) -> bool: + """:class:`bool`: An alias for :meth:`.is_guild_authorised`. + + .. versionadded:: 2.7 + """ + return self.is_guild_authorised() + + def is_user_authorized(self) -> bool: + """:class:`bool`: An alias for :meth:`.is_user_authorised`. + + .. versionadded:: 2.7 + """ + return self.is_user_authorised() + class AutocompleteContext: """Represents context for a slash command's option autocomplete. diff --git a/discord/ext/pages/pagination.py b/discord/ext/pages/pagination.py index dc99996f2a..5fa297e6ee 100644 --- a/discord/ext/pages/pagination.py +++ b/discord/ext/pages/pagination.py @@ -1202,7 +1202,7 @@ async def respond( ) # convert from WebhookMessage to Message reference to bypass # 15min webhook token timeout (non-ephemeral messages only) - if not ephemeral: + if not ephemeral and not msg.flags.ephemeral: msg = await msg.channel.fetch_message(msg.id) else: msg = await interaction.response.send_message( 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/guild.py b/discord/guild.py index b1e937d07b..337abd31c0 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -289,11 +289,11 @@ class Guild(Hashable): ) _PREMIUM_GUILD_LIMITS: ClassVar[dict[int | None, _GuildLimit]] = { - None: _GuildLimit(emoji=50, stickers=5, bitrate=96e3, filesize=26214400), - 0: _GuildLimit(emoji=50, stickers=5, bitrate=96e3, filesize=26214400), - 1: _GuildLimit(emoji=100, stickers=15, bitrate=128e3, filesize=26214400), - 2: _GuildLimit(emoji=150, stickers=30, bitrate=256e3, filesize=52428800), - 3: _GuildLimit(emoji=250, stickers=60, bitrate=384e3, filesize=104857600), + None: _GuildLimit(emoji=50, stickers=5, bitrate=96e3, filesize=10_485_760), + 0: _GuildLimit(emoji=50, stickers=5, bitrate=96e3, filesize=10_485_760), + 1: _GuildLimit(emoji=100, stickers=15, bitrate=128e3, filesize=10_485_760), + 2: _GuildLimit(emoji=150, stickers=30, bitrate=256e3, filesize=52_428_800), + 3: _GuildLimit(emoji=250, stickers=60, bitrate=384e3, filesize=104_857_600), } def __init__(self, *, data: GuildPayload, state: ConnectionState): 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 e7d7fade3e..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 @@ -360,6 +360,48 @@ def followup(self) -> Webhook: } return Webhook.from_state(data=payload, state=self._state) + def is_guild_authorised(self) -> bool: + """:class:`bool`: Checks if the interaction is guild authorised. + + There is an alias for this called :meth:`.is_guild_authorized`. + + .. versionadded:: 2.7 + """ + if self.guild_id: + return self.authorizing_integration_owners.guild_id == self.guild_id + return False + + def is_user_authorised(self) -> bool: + """:class:`bool`: Checks if the interaction is user authorised. + + There is an alias for this called :meth:`.is_user_authorized`. + + .. versionadded:: 2.7 + """ + if self.user: + return self.authorizing_integration_owners.user_id == self.user.id + + # This return should not be called but to make sure it returns the expected value + return False + + def is_guild_authorized(self) -> bool: + """:class:`bool`: Checks if the interaction is guild authorized. + + There is an alias for this called :meth:`.is_guild_authorised`. + + .. versionadded:: 2.7 + """ + return self.is_guild_authorised() + + def is_user_authorized(self) -> bool: + """:class:`bool`: Checks if the interaction is user authorized. + + There is an alias for this called :meth:`.is_user_authorised`. + + .. versionadded:: 2.7 + """ + return self.is_user_authorised() + async def original_response(self) -> InteractionMessage: """|coro| @@ -915,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() @@ -954,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/ui/item.py b/discord/ui/item.py index c822983bce..fd8dc747ad 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -97,6 +97,22 @@ def __repr__(self) -> str: @property def row(self) -> int | None: + """Gets or sets the row position of this item within its parent view. + + The row position determines the vertical placement of the item in the UI. + The value must be an integer between 0 and 4 (inclusive), or ``None`` to indicate + that no specific row is set. + + Returns + ------- + Optional[:class:`int`] + The row position of the item, or ``None`` if not explicitly set. + + Raises + ------ + ValueError + If the row value is not ``None`` and is outside the range [0, 4]. + """ return self._row @row.setter @@ -110,11 +126,29 @@ def row(self, value: int | None): @property def width(self) -> int: + """Gets the width of the item in the UI layout. + + The width determines how much horizontal space this item occupies within its row. + + Returns + ------- + :class:`int` + The width of the item. Defaults to 1. + """ return 1 @property def view(self) -> V | None: - """The underlying view for this item.""" + """Gets the parent view associated with this item. + + The view refers to the container that holds this item. This is typically set + automatically when the item is added to a view. + + Returns + ------- + Optional[:class:`View`] + The parent view of this item, or ``None`` if the item is not attached to any view. + """ return self._view async def callback(self, interaction: Interaction): diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 23b6386a7d..4b87a7e0f3 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 @@ -501,19 +502,22 @@ def create_interaction_response( "type": type, } - if data is not None: - payload["data"] = data + payload["data"] = data if data is not None else {} form = [{"name": "payload_json"}] 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 +526,8 @@ def create_interaction_response( "content_type": "application/octet-stream", } ) - payload["attachments"] = attachments + if 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 diff --git a/requirements/dev.txt b/requirements/dev.txt index 83326ccb3e..b96821f915 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,9 +1,9 @@ -r _.txt -pylint~=3.3.2 +pylint~=3.3.3 pytest~=8.3.4 pytest-asyncio~=0.23.8 # pytest-order~=1.0.1 -mypy~=1.13.0 +mypy~=1.14.0 coverage~=7.6 pre-commit==4.0.1 codespell==2.3.0 diff --git a/requirements/speed.txt b/requirements/speed.txt index c4f3a6edf8..62fa81b491 100644 --- a/requirements/speed.txt +++ b/requirements/speed.txt @@ -1,2 +1,2 @@ -msgspec~=0.18.6 +msgspec~=0.19.0 aiohttp[speedups]