diff --git a/.github/workflows/docs-localization-download.yml b/.github/workflows/docs-localization-download.yml index 34e75a4dc7..9a093dd3f1 100644 --- a/.github/workflows/docs-localization-download.yml +++ b/.github/workflows/docs-localization-download.yml @@ -40,7 +40,7 @@ jobs: working-directory: ./docs - name: "Crowdin" id: crowdin - uses: crowdin/github-action@v2 + uses: crowdin/github-action@v2.5.0 with: upload_sources: false upload_translations: false diff --git a/.github/workflows/docs-localization-upload.yml b/.github/workflows/docs-localization-upload.yml index 521dc80ee3..44a38fd249 100644 --- a/.github/workflows/docs-localization-upload.yml +++ b/.github/workflows/docs-localization-upload.yml @@ -44,7 +44,7 @@ jobs: sphinx-intl update -p ./build/locales ${{ vars.SPHINX_LANGUAGES }} working-directory: ./docs - name: "Crowdin" - uses: crowdin/github-action@v2 + uses: crowdin/github-action@v2.5.0 with: upload_sources: true upload_translations: false 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 228ebbafe4..7f945bc5dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,9 @@ These changes are available on the `master` branch, but have not yet been releas ([#2587](https://github.com/Pycord-Development/pycord/pull/2587/)) - Added optional `filter` parameter to `utils.basic_autocomplete()`. ([#2590](https://github.com/Pycord-Development/pycord/pull/2590)) +- Added role tags: `subscription_listing_id`, `guild_connections`, and + `available_for_purchase`. + ([#2606](https://github.com/Pycord-Development/pycord/pull/2606)) - Added missing `with_counts` parameter to `fetch_guilds` method. ([#2615](https://github.com/Pycord-Development/pycord/pull/2615)) - Added the following missing permissions: `Permissions.use_soundboard`, @@ -38,6 +41,12 @@ These changes are available on the `master` branch, but have not yet been releas ([#2620](https://github.com/Pycord-Development/pycord/pull/2620)) - Added `MediaChannel` channel type. ([#2641](https://github.com/Pycord-Development/pycord/pull/2641)) +- 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 @@ -68,6 +77,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 @@ -81,6 +96,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 diff --git a/discord/_typed_dict.py b/discord/_typed_dict.py deleted file mode 100644 index dfd1e4e9eb..0000000000 --- a/discord/_typed_dict.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -The MIT License (MIT) - -Copyright (c) 2021-present Pycord Development - -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. -""" - -import sys - -# PEP 655 Required and NotRequired were added in python 3.11. This file is simply a -# shortcut import, so we don't have to repeat this import logic across files. -if sys.version_info >= (3, 11): - from typing import NotRequired, Required, TypedDict -else: - from typing_extensions import NotRequired, Required, TypedDict - -__all__ = ( - "Required", - "NotRequired", - "TypedDict", -) diff --git a/discord/_version.py b/discord/_version.py index c1bc153507..ba68799dfe 100644 --- a/discord/_version.py +++ b/discord/_version.py @@ -30,7 +30,7 @@ import warnings from importlib.metadata import PackageNotFoundError, version -from ._typed_dict import TypedDict +from typing_extensions import TypedDict __all__ = ("__version__", "VersionInfo", "version_info") diff --git a/discord/abc.py b/discord/abc.py index a66ebd0d92..f0da08d59d 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -46,7 +46,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 ChannelFlags, MessageFlags from .invite import Invite from .iterators import HistoryIterator @@ -1566,7 +1566,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] @@ -1612,27 +1612,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( @@ -1641,6 +1621,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, @@ -1655,7 +1639,7 @@ async def send( message_reference=reference, stickers=stickers, components=components, - flags=flags, + flags=flags.value, poll=poll, ) finally: @@ -1674,7 +1658,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/message.py b/discord/message.py index 5ebd2aa0ea..55a6bfd6b4 100644 --- a/discord/message.py +++ b/discord/message.py @@ -789,6 +789,7 @@ class Message(Hashable): __slots__ = ( "_state", + "_raw_data", "_edited_timestamp", "_cs_channel_mentions", "_cs_raw_mentions", @@ -842,6 +843,7 @@ def __init__( data: MessagePayload, ): self._state: ConnectionState = state + self._raw_data: MessagePayload = data self.id: int = int(data["id"]) self.webhook_id: int | None = utils._get_as_snowflake(data, "webhook_id") self.reactions: list[Reaction] = [ diff --git a/discord/role.py b/discord/role.py index 1907606e9d..09423b168c 100644 --- a/discord/role.py +++ b/discord/role.py @@ -60,6 +60,10 @@ class RoleTags: While this can be accessed, a useful interface is also provided in the :class:`Role` and :class:`Guild` classes as well. + Role tags are a fairly complex topic, since it's usually hard to determine which role tag combination represents which role type. + We aim to improve the documentation / introduce new attributes in future. + For the meantime read `this `_ if you need detailed information about how role tags work. + .. versionadded:: 1.6 Attributes @@ -68,22 +72,37 @@ class RoleTags: The bot's user ID that manages this role. integration_id: Optional[:class:`int`] The integration ID that manages the role. + subscription_listing_id: Optional[:class:`int`] + The subscription SKU and listing ID of the role. + + .. versionadded:: 2.7 """ __slots__ = ( "bot_id", "integration_id", + "subscription_listing_id", "_premium_subscriber", + "_available_for_purchase", + "_guild_connections", ) def __init__(self, data: RoleTagPayload): self.bot_id: int | None = _get_as_snowflake(data, "bot_id") self.integration_id: int | None = _get_as_snowflake(data, "integration_id") - # NOTE: The API returns "null" for this if it's valid, which corresponds to None. + self.subscription_listing_id: int | None = _get_as_snowflake( + data, "subscription_listing_id" + ) + # NOTE: The API returns "null" for each of the following tags if they are True, and omits them if False. + # However, "null" corresponds to None. # This is different from other fields where "null" means "not there". # So in this case, a value of None is the same as True. # Which means we would need a different sentinel. self._premium_subscriber: Any | None = data.get("premium_subscriber", MISSING) + self._available_for_purchase: Any | None = data.get( + "available_for_purchase", MISSING + ) + self._guild_connections: Any | None = data.get("guild_connections", MISSING) def is_bot_managed(self) -> bool: """Whether the role is associated with a bot.""" @@ -94,13 +113,36 @@ def is_premium_subscriber(self) -> bool: return self._premium_subscriber is None def is_integration(self) -> bool: - """Whether the role is managed by an integration.""" + """Whether the guild manages the role through some form of + integrations such as Twitch or through guild subscriptions. + """ return self.integration_id is not None + def is_available_for_purchase(self) -> bool: + """Whether the role is available for purchase. + + Returns ``True`` if the role is available for purchase, and + ``False`` if it is not available for purchase or if the role + is not linked to a guild subscription. + + .. versionadded:: 2.7 + """ + return self._available_for_purchase is None + + def is_guild_connections_role(self) -> bool: + """Whether the role is a guild connections role. + + .. versionadded:: 2.7 + """ + return self._guild_connections is None + def __repr__(self) -> str: return ( f"" + f"subscription_listing_id={self.subscription_listing_id} " + f"premium_subscriber={self.is_premium_subscriber()} " + f"available_for_purchase={self.is_available_for_purchase()} " + f"guild_connections={self.is_guild_connections_role()}>" ) @@ -167,8 +209,10 @@ class Role(Hashable): operators on the role objects themselves. managed: :class:`bool` - Indicates if the role is managed by the guild through some form of - integrations such as Twitch. + Indicates if the role is managed by the guild. + This is true if any of :meth:`Role.is_integration`, :meth:`Role.is_premium_subscriber`, + :meth:`Role.is_bot_managed` or :meth:`Role.is_guild_connections_role` + is ``True``. mentionable: :class:`bool` Indicates if the role can be mentioned by users. tags: Optional[:class:`RoleTags`] @@ -287,7 +331,8 @@ def is_premium_subscriber(self) -> bool: return self.tags is not None and self.tags.is_premium_subscriber() def is_integration(self) -> bool: - """Whether the role is managed by an integration. + """Whether the guild manages the role through some form of + integrations such as Twitch or through guild subscriptions. .. versionadded:: 1.6 """ @@ -305,6 +350,24 @@ def is_assignable(self) -> bool: and (me.top_role > self or me.id == self.guild.owner_id) ) + def is_available_for_purchase(self) -> bool: + """Whether the role is available for purchase. + + Returns ``True`` if the role is available for purchase, and + ``False`` if it is not available for purchase or if the + role is not linked to a guild subscription. + + .. versionadded:: 2.7 + """ + return self.tags is not None and self.tags.is_available_for_purchase() + + def is_guild_connections_role(self) -> bool: + """Whether the role is a guild connections role. + + .. versionadded:: 2.7 + """ + return self.tags is not None and self.tags.is_guild_connections_role() + @property def permissions(self) -> Permissions: """Returns the role's permissions.""" diff --git a/discord/types/activity.py b/discord/types/activity.py index 3c610e3138..c19e9b18c1 100644 --- a/discord/types/activity.py +++ b/discord/types/activity.py @@ -27,7 +27,8 @@ from typing import Literal -from .._typed_dict import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict + from .snowflake import Snowflake from .user import PartialUser diff --git a/discord/types/appinfo.py b/discord/types/appinfo.py index 8d891acbed..c22f665745 100644 --- a/discord/types/appinfo.py +++ b/discord/types/appinfo.py @@ -25,7 +25,8 @@ from __future__ import annotations -from .._typed_dict import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict + from .snowflake import Snowflake from .team import Team from .user import User diff --git a/discord/types/application_role_connection.py b/discord/types/application_role_connection.py index 40bcae1014..cf797b8efa 100644 --- a/discord/types/application_role_connection.py +++ b/discord/types/application_role_connection.py @@ -26,7 +26,7 @@ from typing import Literal -from .._typed_dict import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict ApplicationRoleConnectionMetadataType = Literal[1, 2, 3, 4, 5, 6, 7, 8] diff --git a/discord/types/audit_log.py b/discord/types/audit_log.py index d543f9e3b7..0575457e7a 100644 --- a/discord/types/audit_log.py +++ b/discord/types/audit_log.py @@ -27,7 +27,8 @@ from typing import Literal, Union -from .._typed_dict import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict + from .automod import AutoModRule from .channel import ChannelType, PermissionOverwrite, VideoQualityMode from .guild import ( diff --git a/discord/types/automod.py b/discord/types/automod.py index 4f13b46ae0..0417e78497 100644 --- a/discord/types/automod.py +++ b/discord/types/automod.py @@ -24,7 +24,8 @@ from typing import Literal -from .._typed_dict import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict + from .snowflake import Snowflake AutoModTriggerType = Literal[1, 2, 3, 4, 5] diff --git a/discord/types/channel.py b/discord/types/channel.py index 822ee9283f..fe5e097eca 100644 --- a/discord/types/channel.py +++ b/discord/types/channel.py @@ -27,7 +27,8 @@ from typing import Literal, Union -from .._typed_dict import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict + from ..enums import SortOrder from ..flags import ChannelFlags from .snowflake import Snowflake diff --git a/discord/types/components.py b/discord/types/components.py index 4c0ce30ad4..7b05f8bf08 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -27,7 +27,8 @@ from typing import Literal, Union -from .._typed_dict import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict + from .channel import ChannelType from .emoji import PartialEmoji from .snowflake import Snowflake diff --git a/discord/types/embed.py b/discord/types/embed.py index dbfcb14c87..33f5f0d942 100644 --- a/discord/types/embed.py +++ b/discord/types/embed.py @@ -27,7 +27,7 @@ from typing import Literal -from .._typed_dict import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict class EmbedFooter(TypedDict): diff --git a/discord/types/guild.py b/discord/types/guild.py index cac645b272..9ada5e194e 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -27,7 +27,8 @@ from typing import Literal -from .._typed_dict import NotRequired, Required, TypedDict +from typing_extensions import NotRequired, Required, TypedDict + from .activity import PartialPresenceUpdate from .channel import GuildChannel from .emoji import Emoji diff --git a/discord/types/integration.py b/discord/types/integration.py index 219992d225..1a6c8b70f1 100644 --- a/discord/types/integration.py +++ b/discord/types/integration.py @@ -27,7 +27,8 @@ from typing import Literal, Union -from .._typed_dict import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict + from .snowflake import Snowflake from .user import User diff --git a/discord/types/interactions.py b/discord/types/interactions.py index 2c7fd520ab..37904a580a 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -42,7 +42,7 @@ from .message import AllowedMentions, Message from ..interactions import InteractionChannel -from .._typed_dict import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict ApplicationCommandType = Literal[1, 2, 3] diff --git a/discord/types/invite.py b/discord/types/invite.py index 7f1e23b96e..796d27ccb4 100644 --- a/discord/types/invite.py +++ b/discord/types/invite.py @@ -27,7 +27,8 @@ from typing import Literal, Union -from .._typed_dict import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict + from .appinfo import PartialAppInfo from .channel import PartialChannel from .guild import InviteGuild, _GuildPreviewUnique diff --git a/discord/types/message.py b/discord/types/message.py index f138609d1b..20a54204bf 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -41,7 +41,7 @@ if TYPE_CHECKING: from .interactions import InteractionMetadata, MessageInteraction -from .._typed_dict import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict class ChannelMention(TypedDict): diff --git a/discord/types/monetization.py b/discord/types/monetization.py index 8b186c83e0..a12ee5dfea 100644 --- a/discord/types/monetization.py +++ b/discord/types/monetization.py @@ -27,7 +27,8 @@ from typing import Literal -from .._typed_dict import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict + from .snowflake import Snowflake SKUType = Literal[5, 6] diff --git a/discord/types/onboarding.py b/discord/types/onboarding.py index 7bfa44dc13..31dda34ca8 100644 --- a/discord/types/onboarding.py +++ b/discord/types/onboarding.py @@ -26,7 +26,8 @@ from typing import Literal, TypedDict -from .._typed_dict import NotRequired +from typing_extensions import NotRequired + from .emoji import Emoji from .snowflake import Snowflake, SnowflakeList diff --git a/discord/types/poll.py b/discord/types/poll.py index 21e54431e6..ed747f93da 100644 --- a/discord/types/poll.py +++ b/discord/types/poll.py @@ -26,7 +26,8 @@ from typing import Literal, TypedDict -from .._typed_dict import NotRequired +from typing_extensions import NotRequired + from .emoji import Emoji PollLayoutType = Literal[1] diff --git a/discord/types/raw_models.py b/discord/types/raw_models.py index 2d0698eca3..1a7feee059 100644 --- a/discord/types/raw_models.py +++ b/discord/types/raw_models.py @@ -25,7 +25,8 @@ from __future__ import annotations -from .._typed_dict import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict + from .automod import AutoModAction, AutoModTriggerType from .emoji import PartialEmoji from .member import Member diff --git a/discord/types/role.py b/discord/types/role.py index d387d38b77..c1354f1f0f 100644 --- a/discord/types/role.py +++ b/discord/types/role.py @@ -25,7 +25,8 @@ from __future__ import annotations -from .._typed_dict import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict + from .snowflake import Snowflake diff --git a/discord/types/sticker.py b/discord/types/sticker.py index 129b14cc46..95945462e6 100644 --- a/discord/types/sticker.py +++ b/discord/types/sticker.py @@ -27,7 +27,8 @@ from typing import Literal, Union -from .._typed_dict import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict + from .snowflake import Snowflake from .user import User diff --git a/discord/types/threads.py b/discord/types/threads.py index aab6d95794..447027183b 100644 --- a/discord/types/threads.py +++ b/discord/types/threads.py @@ -27,7 +27,8 @@ from typing import Literal -from .._typed_dict import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict + from ..flags import ChannelFlags from .snowflake import Snowflake diff --git a/discord/types/voice.py b/discord/types/voice.py index 4dc485cbf7..732f9826d7 100644 --- a/discord/types/voice.py +++ b/discord/types/voice.py @@ -27,7 +27,8 @@ from typing import Literal -from .._typed_dict import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict + from .member import MemberWithUser from .snowflake import Snowflake diff --git a/discord/types/webhook.py b/discord/types/webhook.py index 20fa0e900e..a312b30a41 100644 --- a/discord/types/webhook.py +++ b/discord/types/webhook.py @@ -27,7 +27,8 @@ from typing import Literal -from .._typed_dict import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict + from .channel import PartialChannel from .snowflake import Snowflake from .user import User diff --git a/discord/types/widget.py b/discord/types/widget.py index 1ebeb959a3..d327fa58fb 100644 --- a/discord/types/widget.py +++ b/discord/types/widget.py @@ -25,7 +25,8 @@ from __future__ import annotations -from .._typed_dict import NotRequired, TypedDict +from typing_extensions import NotRequired, TypedDict + from .activity import Activity from .snowflake import Snowflake from .user import User 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/pyproject.toml b/pyproject.toml index 97095f7fc0..5cca2176ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] requires = [ - "setuptools>=62.6,<=75.6.0", + "setuptools>=62.6,<=75.8.0", "setuptools-scm>=6.2,<=8.1.0", ] build-backend = "setuptools.build_meta" diff --git a/requirements/_.txt b/requirements/_.txt index 5305a96bd1..e8dfe84d1e 100644 --- a/requirements/_.txt +++ b/requirements/_.txt @@ -1,2 +1,2 @@ aiohttp>=3.6.0,<4.0 -typing_extensions>=4,<5; python_version < "3.11" +typing_extensions>=4,<5 diff --git a/requirements/dev.txt b/requirements/dev.txt index 83326ccb3e..96ca723bdf 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.1 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]