From 8d8d5e180adf8961cdac625e43f03bce99654f56 Mon Sep 17 00:00:00 2001 From: owocado <24418520+owocado@users.noreply.github.com> Date: Fri, 20 Jun 2025 23:53:35 +0530 Subject: [PATCH 001/138] fix docs for soundboard_sound_update event --- docs/api.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index e366f63bf312..dda5553b7074 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1354,8 +1354,10 @@ Soundboard .. versionadded:: 2.5 - :param sound: The soundboard sound that was updated. - :type sound: :class:`SoundboardSound` + :param before: The soundboard sound before the update. + :type before: :class:`SoundboardSound` + :param after: The soundboard sound after the update. + :type after: :class:`SoundboardSound` Stages From 7f16a06479bdae27b24916e784414613e8473f5d Mon Sep 17 00:00:00 2001 From: Leonardo Date: Fri, 20 Jun 2025 20:24:06 +0200 Subject: [PATCH 002/138] Copy Select options when creating View class --- discord/components.py | 3 +++ discord/ui/view.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/discord/components.py b/discord/components.py index b3f978eb1bc4..b62ab6bf9e08 100644 --- a/discord/components.py +++ b/discord/components.py @@ -442,6 +442,9 @@ def to_dict(self) -> SelectOptionPayload: return payload + def copy(self) -> SelectOption: + return self.__class__.from_dict(self.to_dict()) + class TextInput(Component): """Represents a text input from the Discord Bot UI Kit. diff --git a/discord/ui/view.py b/discord/ui/view.py index dd44944ec0ef..f27b71eeb90e 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -33,6 +33,7 @@ import time import os from .item import Item, ItemCallbackType +from .select import Select from .dynamic import DynamicItem from ..components import ( Component, @@ -179,6 +180,8 @@ def _init_children(self) -> List[Item[Self]]: item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) item.callback = _ViewCallback(func, self, item) # type: ignore item._view = self + if isinstance(item, Select): + item.options = [option.copy() for option in item.options] setattr(self, func.__name__, item) children.append(item) return children From 3e48119654411d1fbf4dafaae8f06c6a0d053cfd Mon Sep 17 00:00:00 2001 From: Maxine Date: Fri, 20 Jun 2025 20:24:55 +0200 Subject: [PATCH 003/138] Fix unreachable code not triggering privileged intent error --- discord/shard.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/shard.py b/discord/shard.py index 454fd5e2895a..cd10cc265244 100644 --- a/discord/shard.py +++ b/discord/shard.py @@ -517,10 +517,10 @@ async def connect(self, *, reconnect: bool = True) -> None: if item.type == EventType.close: await self.close() if isinstance(item.error, ConnectionClosed): - if item.error.code != 1000: - raise item.error if item.error.code == 4014: raise PrivilegedIntentsRequired(item.shard.id) from None + if item.error.code != 1000: + raise item.error return elif item.type in (EventType.identify, EventType.resume): await item.shard.reidentify(item.error) From 0ce11544e3a32d0d4b9d867e3b1d356c4438e196 Mon Sep 17 00:00:00 2001 From: Dep <70801324+Depreca1ed@users.noreply.github.com> Date: Fri, 20 Jun 2025 23:55:47 +0530 Subject: [PATCH 004/138] Add discord.Permissions.apps() --- discord/permissions.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/discord/permissions.py b/discord/permissions.py index b553e2578161..c234ad5f32b0 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -363,6 +363,16 @@ def elevated(cls) -> Self: """ return cls(0b0000_0000_0000_0000_0000_0001_0000_0100_0111_0000_0000_0000_0010_0000_0011_1110) + @classmethod + def apps(cls) -> Self: + """A factory method that creates a :class:`Permissions` with all + "Apps" permissions from the official Discord UI set to ``True``. + + + .. versionadded:: 2.6 + """ + return cls(0b0000_0000_0000_0100_0000_0000_1000_0000_1000_0000_0000_0000_0000_0000_0000_0000) + @classmethod def events(cls) -> Self: """A factory method that creates a :class:`Permissions` with all From aa5f4bdd7a8327fdcd1d68c96c1cc97c87cca534 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Fri, 20 Jun 2025 20:27:37 +0200 Subject: [PATCH 005/138] Fix poll parameter defaulting to MISSING instead of None for Context.send --- discord/ext/commands/context.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index 7198c12064ec..b5b96c15f9a6 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -924,7 +924,7 @@ async def send( suppress_embeds: bool = False, ephemeral: bool = False, silent: bool = False, - poll: Poll = MISSING, + poll: Optional[Poll] = None, ) -> Message: """|coro| @@ -1014,10 +1014,12 @@ async def send( .. versionadded:: 2.2 - poll: :class:`~discord.Poll` + poll: Optional[:class:`~discord.Poll`] The poll to send with this message. .. versionadded:: 2.4 + .. versionchanged:: 2.6 + This can now be ``None`` and defaults to ``None`` instead of ``MISSING``. Raises -------- @@ -1072,7 +1074,7 @@ async def send( 'suppress_embeds': suppress_embeds, 'ephemeral': ephemeral, 'silent': silent, - 'poll': poll, + 'poll': MISSING if poll is None else poll, } if self.interaction.response.is_done(): From 680fe9bc808422cddc04968aa003ea6edf9090be Mon Sep 17 00:00:00 2001 From: Tari <65512380+Tari-dev@users.noreply.github.com> Date: Fri, 20 Jun 2025 23:58:08 +0530 Subject: [PATCH 006/138] Add channel select to component type documentation --- docs/interactions/api.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index feab669073ea..294a3b13a781 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -329,6 +329,10 @@ Enumerations Represents a select in which both users and roles can be selected. + .. attribute:: channel_select + + Represents a channel select component. + .. class:: ButtonStyle Represents the style of the button component. From e84edf473c1372c7c04b60abbbde4d660ce4256d Mon Sep 17 00:00:00 2001 From: Leonardo Date: Fri, 20 Jun 2025 20:28:59 +0200 Subject: [PATCH 007/138] Add colours for new Discord themes --- discord/colour.py | 83 +++++++++++++++++++++++++++++++++++++++----- tests/test_colour.py | 13 ++++--- 2 files changed, 83 insertions(+), 13 deletions(-) diff --git a/discord/colour.py b/discord/colour.py index 7e3a37132a11..8c40dac35a1e 100644 --- a/discord/colour.py +++ b/discord/colour.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations import colorsys @@ -457,20 +458,59 @@ def greyple(cls) -> Self: """ return cls(0x99AAB5) + @classmethod + def ash_theme(cls) -> Self: + """A factory method that returns a :class:`Colour` with a value of ``0x2E2E34``. + + This will appear transparent on Discord's ash theme. + + .. colour:: #2E2E34 + + .. versionadded:: 2.6 + """ + return cls(0x2E2E34) + @classmethod def dark_theme(cls) -> Self: - """A factory method that returns a :class:`Colour` with a value of ``0x313338``. + """A factory method that returns a :class:`Colour` with a value of ``0x1A1A1E``. This will appear transparent on Discord's dark theme. - .. colour:: #313338 + .. colour:: #1A1A1E .. versionadded:: 1.5 .. versionchanged:: 2.2 Updated colour from previous ``0x36393F`` to reflect discord theme changes. + + .. versionchanged:: 2.6 + Updated colour from previous ``0x313338`` to reflect discord theme changes. """ - return cls(0x313338) + return cls(0x1A1A1E) + + @classmethod + def onyx_theme(cls) -> Self: + """A factory method that returns a :class:`Colour` with a value of ``0x070709``. + + This will appear transparent on Discord's onyx theme. + + .. colour:: #070709 + + .. versionadded:: 2.6 + """ + return cls(0x070709) + + @classmethod + def light_theme(cls) -> Self: + """A factory method that returns a :class:`Colour` with a value of ``0xFBFBFB``. + + This will appear transparent on Discord's light theme. + + .. colour:: #FBFBFB + + .. versionadded:: 2.6 + """ + return cls(0xFBFBFB) @classmethod def fuchsia(cls) -> Self: @@ -492,25 +532,52 @@ def yellow(cls) -> Self: """ return cls(0xFEE75C) + @classmethod + def ash_embed(cls) -> Self: + """A factory method that returns a :class:`Colour` with a value of ``0x37373E``. + + .. colour:: #37373E + + .. versionadded:: 2.6 + + """ + return cls(0x37373E) + @classmethod def dark_embed(cls) -> Self: - """A factory method that returns a :class:`Colour` with a value of ``0x2B2D31``. + """A factory method that returns a :class:`Colour` with a value of ``0x242429``. - .. colour:: #2B2D31 + .. colour:: #242429 .. versionadded:: 2.2 + + .. versionchanged:: 2.6 + Updated colour from previous ``0x2B2D31`` to reflect discord theme changes. + """ + return cls(0x242429) + + @classmethod + def onyx_embed(cls) -> Self: + """A factory method that returns a :class:`Colour` with a value of ``0x131416``. + + .. colour:: #131416 + + .. versionadded:: 2.6 """ - return cls(0x2B2D31) + return cls(0x131416) @classmethod def light_embed(cls) -> Self: - """A factory method that returns a :class:`Colour` with a value of ``0xEEEFF1``. + """A factory method that returns a :class:`Colour` with a value of ``0xFFFFFF``. .. colour:: #EEEFF1 .. versionadded:: 2.2 + + .. versionchanged:: 2.6 + Updated colour from previous ``0xEEEFF1`` to reflect discord theme changes. """ - return cls(0xEEEFF1) + return cls(0xFFFFFF) @classmethod def pink(cls) -> Self: diff --git a/tests/test_colour.py b/tests/test_colour.py index b79f153f06e8..1515b2cb47e4 100644 --- a/tests/test_colour.py +++ b/tests/test_colour.py @@ -106,11 +106,16 @@ def test_from_str_failures(value): (discord.Colour.og_blurple(), 0x7289DA), (discord.Colour.blurple(), 0x5865F2), (discord.Colour.greyple(), 0x99AAB5), - (discord.Colour.dark_theme(), 0x313338), + (discord.Colour.ash_theme(), 0x2E2E34), + (discord.Colour.dark_theme(), 0x1A1A1E), + (discord.Colour.onyx_theme(), 0x070709), + (discord.Colour.light_theme(), 0xFBFBFB), (discord.Colour.fuchsia(), 0xEB459E), (discord.Colour.yellow(), 0xFEE75C), - (discord.Colour.dark_embed(), 0x2B2D31), - (discord.Colour.light_embed(), 0xEEEFF1), + (discord.Colour.ash_embed(), 0x37373E), + (discord.Colour.dark_embed(), 0x242429), + (discord.Colour.onyx_embed(), 0x131416), + (discord.Colour.light_embed(), 0xFFFFFF), (discord.Colour.pink(), 0xEB459F), ], ) @@ -118,8 +123,6 @@ def test_static_colours(value, expected): assert value.value == expected - - @pytest.mark.parametrize( ('value', 'property', 'expected'), [ From e177b4a70502cf248ae0d682ed011875999ffd1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20N=C3=B8rgaard?= Date: Fri, 20 Jun 2025 19:30:10 +0100 Subject: [PATCH 008/138] Fix EmbedMediaProxy boolean check --- discord/embeds.py | 6 ++++++ tests/test_embed.py | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/discord/embeds.py b/discord/embeds.py index 7f84e410d341..6bd057ac86a8 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -61,6 +61,12 @@ def __init__(self, layer: Dict[str, Any]): super().__init__(layer) self._flags = self.__dict__.pop('flags', 0) + def __bool__(self) -> bool: + # This is a nasty check to see if we only have the `_flags` attribute which is created regardless in init. + # Had we had any of the other items, like image/video data this would be >1 and therefor + # would not be "empty". + return len(self.__dict__) > 1 + @property def flags(self) -> AttachmentFlags: return AttachmentFlags._from_value(self._flags or 0) diff --git a/tests/test_embed.py b/tests/test_embed.py index 3efedd6a57be..004f73e3bc91 100644 --- a/tests/test_embed.py +++ b/tests/test_embed.py @@ -267,3 +267,14 @@ def test_embed_colour_setter_failure(value): embed = discord.Embed() with pytest.raises(TypeError): embed.colour = value + +@pytest.mark.parametrize( + ('title', 'return_val'), + [ + ('test', True), + (None, False) + ] +) +def test_embed_truthiness(title: str, return_val: bool) -> None: + embed = discord.Embed(title=title) + assert bool(embed) is return_val From ef06d7d9db2b868c57f45fe1252d055490ad31a9 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Fri, 20 Jun 2025 20:30:37 +0200 Subject: [PATCH 009/138] Add Interaction.filesize_limit --- discord/interactions.py | 8 +++++++- discord/types/interactions.py | 1 + tests/test_app_commands_invoke.py | 2 ++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/discord/interactions.py b/discord/interactions.py index a983d8ab04f6..cb9a21e88753 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -154,6 +154,10 @@ class Interaction(Generic[ClientT]): The context of the interaction. .. versionadded:: 2.4 + filesize_limit: int + The maximum number of bytes a file can have when responding to this interaction. + + .. versionadded:: 2.6 """ __slots__: Tuple[str, ...] = ( @@ -172,7 +176,8 @@ class Interaction(Generic[ClientT]): 'command_failed', 'entitlement_sku_ids', 'entitlements', - "context", + 'context', + 'filesize_limit', '_integration_owners', '_permissions', '_app_permissions', @@ -214,6 +219,7 @@ def _from_data(self, data: InteractionPayload): self.application_id: int = int(data['application_id']) self.entitlement_sku_ids: List[int] = [int(x) for x in data.get('entitlement_skus', []) or []] self.entitlements: List[Entitlement] = [Entitlement(self._state, x) for x in data.get('entitlements', [])] + self.filesize_limit: int = data['attachment_size_limit'] # This is not entirely useful currently, unsure how to expose it in a way that it is. self._integration_owners: Dict[int, Snowflake] = { int(k): int(v) for k, v in data.get('authorizing_integration_owners', {}).items() diff --git a/discord/types/interactions.py b/discord/types/interactions.py index 3f3516c3a696..3e814b49df6e 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -233,6 +233,7 @@ class _BaseInteraction(TypedDict): entitlements: NotRequired[List[Entitlement]] authorizing_integration_owners: Dict[Literal['0', '1'], Snowflake] context: NotRequired[InteractionContextType] + attachment_size_limit: int class PingInteraction(_BaseInteraction): diff --git a/tests/test_app_commands_invoke.py b/tests/test_app_commands_invoke.py index 35915c19b9ee..6366096f01a1 100644 --- a/tests/test_app_commands_invoke.py +++ b/tests/test_app_commands_invoke.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations @@ -90,6 +91,7 @@ def __init__( "version": 1, "type": 2, "data": self._get_command_data(command, self._get_command_options(**options)), + "attachment_size_limit": 0, } super().__init__(data=data, state=client._connection) From b28a4a115e72977a9091091983a1213e91f8a5e9 Mon Sep 17 00:00:00 2001 From: Michael H Date: Fri, 20 Jun 2025 14:31:55 -0400 Subject: [PATCH 010/138] Fix potentially stuck ratelimit buckets --- discord/http.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/discord/http.py b/discord/http.py index 6617efa2708b..eb1dbbd06832 100644 --- a/discord/http.py +++ b/discord/http.py @@ -461,7 +461,12 @@ async def acquire(self) -> None: future = self._loop.create_future() self._pending_requests.append(future) try: - await future + while not future.done(): + # 30 matches the smallest allowed max_ratelimit_timeout + max_wait_time = self.expires - self._loop.time() if self.expires else 30 + await asyncio.wait([future], timeout=max_wait_time) + if not future.done(): + await self._refresh() except: future.cancel() if self.remaining > 0 and not future.cancelled(): From adb93f4a606ae21a87b46b3f150a75ed6c7d86bb Mon Sep 17 00:00:00 2001 From: Gooraeng <101193491+Gooraeng@users.noreply.github.com> Date: Sat, 21 Jun 2025 03:36:24 +0900 Subject: [PATCH 011/138] Add deprecation warning to create_guild functions --- discord/client.py | 6 +++++- discord/template.py | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/discord/client.py b/discord/client.py index b997bd96f4af..daf2b8855446 100644 --- a/discord/client.py +++ b/discord/client.py @@ -67,7 +67,7 @@ from .http import HTTPClient from .state import ConnectionState from . import utils -from .utils import MISSING, time_snowflake +from .utils import MISSING, time_snowflake, deprecated from .object import Object from .backoff import ExponentialBackoff from .webhook import Webhook @@ -2388,6 +2388,7 @@ async def fetch_guild_preview(self, guild_id: int) -> GuildPreview: data = await self.http.get_guild_preview(guild_id) return GuildPreview(data=data, state=self._connection) + @deprecated() async def create_guild( self, *, @@ -2408,6 +2409,9 @@ async def create_guild( This function will now raise :exc:`ValueError` instead of ``InvalidArgument``. + .. deprecated:: 2.6 + This function is deprecated and will be removed in a future version. + Parameters ---------- name: :class:`str` diff --git a/discord/template.py b/discord/template.py index 409cdc7d9c55..691be2cafa67 100644 --- a/discord/template.py +++ b/discord/template.py @@ -25,7 +25,7 @@ from __future__ import annotations from typing import Any, Optional, TYPE_CHECKING, List -from .utils import parse_time, _bytes_to_base64_data, MISSING +from .utils import parse_time, _bytes_to_base64_data, MISSING, deprecated from .guild import Guild # fmt: off @@ -164,6 +164,7 @@ def __repr__(self) -> str: f' creator={self.creator!r} source_guild={self.source_guild!r} is_dirty={self.is_dirty}>' ) + @deprecated() async def create_guild(self, name: str, icon: bytes = MISSING) -> Guild: """|coro| @@ -178,6 +179,9 @@ async def create_guild(self, name: str, icon: bytes = MISSING) -> Guild: This function will now raise :exc:`ValueError` instead of ``InvalidArgument``. + .. deprecated:: 2.6 + This function is deprecated and will be removed in a future version. + Parameters ---------- name: :class:`str` From 4a8817af2b89c9833ef4d889aabd6a57acad7a30 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Fri, 20 Jun 2025 20:49:20 +0200 Subject: [PATCH 012/138] Document return object for Guild.create_template --- discord/guild.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/discord/guild.py b/discord/guild.py index 20a50d4e932f..291363b1885c 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -2921,6 +2921,11 @@ async def create_template(self, *, name: str, description: str = MISSING) -> Tem The name of the template. description: :class:`str` The description of the template. + + Returns + -------- + :class:`Template` + The created template. """ from .template import Template From 667e7c906523a20fea8313b3eb59bdd8019525ff Mon Sep 17 00:00:00 2001 From: Roberto Scifo Date: Fri, 20 Jun 2025 20:55:44 +0200 Subject: [PATCH 013/138] Fixed to_dict() bug for user-defined embed classes --- discord/embeds.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/embeds.py b/discord/embeds.py index 6bd057ac86a8..f55c7cac1346 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -743,7 +743,7 @@ def to_dict(self) -> EmbedData: # fmt: off result = { key[1:]: getattr(self, key) - for key in self.__slots__ + for key in Embed.__slots__ if key[0] == '_' and hasattr(self, key) } # fmt: on From 2bcbd49bc6d618736e2aa2c5c4d6beb76e33238c Mon Sep 17 00:00:00 2001 From: owocado <24418520+owocado@users.noreply.github.com> Date: Sat, 21 Jun 2025 00:27:10 +0530 Subject: [PATCH 014/138] Add __repr__ method to various classes --- discord/app_commands/installs.py | 6 ++++++ discord/app_commands/models.py | 6 ++++++ discord/ext/commands/parameters.py | 3 +++ discord/interactions.py | 3 +++ 4 files changed, 18 insertions(+) diff --git a/discord/app_commands/installs.py b/discord/app_commands/installs.py index 5ac033245ab7..e00d13724031 100644 --- a/discord/app_commands/installs.py +++ b/discord/app_commands/installs.py @@ -57,6 +57,9 @@ def __init__(self, *, guild: Optional[bool] = None, user: Optional[bool] = None) self._guild: Optional[bool] = guild self._user: Optional[bool] = user + def __repr__(self): + return f'' + @property def guild(self) -> bool: """:class:`bool`: Whether the integration is a guild install.""" @@ -142,6 +145,9 @@ def __init__( self._dm_channel: Optional[bool] = dm_channel self._private_channel: Optional[bool] = private_channel + def __repr__(self) -> str: + return f'' + @property def guild(self) -> bool: """:class:`bool`: Whether the context allows usage in a guild.""" diff --git a/discord/app_commands/models.py b/discord/app_commands/models.py index e8a96784b87c..dcf70d41e0a4 100644 --- a/discord/app_commands/models.py +++ b/discord/app_commands/models.py @@ -1063,6 +1063,9 @@ def __init__(self, *, data: ApplicationCommandPermissions, guild: Guild, state: self.target: Union[Object, User, Member, Role, AllChannels, GuildChannel] = _object + def __repr__(self) -> str: + return f'' + def to_dict(self) -> ApplicationCommandPermissions: return { 'id': self.target.id, @@ -1106,6 +1109,9 @@ def __init__(self, *, data: GuildApplicationCommandPermissions, state: Connectio AppCommandPermissions(data=value, guild=guild, state=self._state) for value in data['permissions'] ] + def __repr__(self) -> str: + return f'' + def to_dict(self) -> Dict[str, Any]: return {'permissions': [p.to_dict() for p in self.permissions]} diff --git a/discord/ext/commands/parameters.py b/discord/ext/commands/parameters.py index 196530d94c8b..2640902a34b5 100644 --- a/discord/ext/commands/parameters.py +++ b/discord/ext/commands/parameters.py @@ -109,6 +109,9 @@ def __init__( self._fallback = False self._displayed_name = displayed_name + def __repr__(self) -> str: + return f'<{self.__class__.__name__} name={self._name!r} required={self.required}>' + def replace( self, *, diff --git a/discord/interactions.py b/discord/interactions.py index cb9a21e88753..abe47efa2b7d 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -712,6 +712,9 @@ def __init__( self.type: InteractionResponseType = type self._update(data) + def __repr__(self) -> str: + return f'' + def _update(self, data: InteractionCallbackPayload) -> None: interaction = data['interaction'] From c524f655beecd5ed4c11f900fe7077fe17248e4f Mon Sep 17 00:00:00 2001 From: owocado <24418520+owocado@users.noreply.github.com> Date: Sat, 21 Jun 2025 00:27:52 +0530 Subject: [PATCH 015/138] Use human_join internal helper in BaseChannelTransformer --- discord/app_commands/transformers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/app_commands/transformers.py b/discord/app_commands/transformers.py index c18485d8c0b4..58253a497290 100644 --- a/discord/app_commands/transformers.py +++ b/discord/app_commands/transformers.py @@ -52,7 +52,7 @@ from ..abc import GuildChannel from ..threads import Thread from ..enums import Enum as InternalEnum, AppCommandOptionType, ChannelType, Locale -from ..utils import MISSING, maybe_coroutine +from ..utils import MISSING, maybe_coroutine, _human_join from ..user import User from ..role import Role from ..member import Member @@ -631,7 +631,7 @@ def __init__(self, *channel_types: Type[Any]) -> None: display_name = channel_types[0].__name__ types = CHANNEL_TO_TYPES[channel_types[0]] else: - display_name = '{}, and {}'.format(', '.join(t.__name__ for t in channel_types[:-1]), channel_types[-1].__name__) + display_name = _human_join([t.__name__ for t in channel_types]) types = [] for t in channel_types: From 826aa22868b1552a6a6f28d567b8f62c694aab40 Mon Sep 17 00:00:00 2001 From: owocado <24418520+owocado@users.noreply.github.com> Date: Sat, 21 Jun 2025 00:28:30 +0530 Subject: [PATCH 016/138] Add platform key in __repr__ method for Game and Streaming --- discord/activity.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/activity.py b/discord/activity.py index 324bea42f290..0fc0faa64652 100644 --- a/discord/activity.py +++ b/discord/activity.py @@ -418,7 +418,7 @@ def __str__(self) -> str: return str(self.name) def __repr__(self) -> str: - return f'' + return f'' def to_dict(self) -> Dict[str, Any]: timestamps: Dict[str, Any] = {} @@ -514,7 +514,7 @@ def __str__(self) -> str: return str(self.name) def __repr__(self) -> str: - return f'' + return f'' @property def twitch_name(self) -> Optional[str]: From fe942d1e16d3c6fa1b350fa971d80b72ab0bba1c Mon Sep 17 00:00:00 2001 From: owocado <24418520+owocado@users.noreply.github.com> Date: Sat, 21 Jun 2025 00:29:07 +0530 Subject: [PATCH 017/138] Properly transform media channels in app commands --- discord/app_commands/transformers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/discord/app_commands/transformers.py b/discord/app_commands/transformers.py index 58253a497290..212991cbe372 100644 --- a/discord/app_commands/transformers.py +++ b/discord/app_commands/transformers.py @@ -689,6 +689,7 @@ async def transform(self, interaction: Interaction[ClientT], value: Any, /): ChannelType.news, ChannelType.category, ChannelType.forum, + ChannelType.media, ], GuildChannel: [ ChannelType.stage_voice, @@ -697,6 +698,7 @@ async def transform(self, interaction: Interaction[ClientT], value: Any, /): ChannelType.news, ChannelType.category, ChannelType.forum, + ChannelType.media, ], AppCommandThread: [ChannelType.news_thread, ChannelType.private_thread, ChannelType.public_thread], Thread: [ChannelType.news_thread, ChannelType.private_thread, ChannelType.public_thread], @@ -704,7 +706,7 @@ async def transform(self, interaction: Interaction[ClientT], value: Any, /): VoiceChannel: [ChannelType.voice], TextChannel: [ChannelType.text, ChannelType.news], CategoryChannel: [ChannelType.category], - ForumChannel: [ChannelType.forum], + ForumChannel: [ChannelType.forum, ChannelType.media], } BUILT_IN_TRANSFORMERS: Dict[Any, Transformer] = { From d00c124883c50910652b9d6cf8e6b1908930d9e3 Mon Sep 17 00:00:00 2001 From: owocado <24418520+owocado@users.noreply.github.com> Date: Sat, 21 Jun 2025 00:31:48 +0530 Subject: [PATCH 018/138] Add MessageApplication.__str__ --- discord/message.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/discord/message.py b/discord/message.py index 547e9c433aed..d6a26c7d0d6f 100644 --- a/discord/message.py +++ b/discord/message.py @@ -989,6 +989,9 @@ def __init__(self, *, state: ConnectionState, data: MessageApplicationPayload) - self._icon: Optional[str] = data['icon'] self._cover_image: Optional[str] = data.get('cover_image') + def __str__(self) -> str: + return self.name + def __repr__(self) -> str: return f'' From 4862ea22fdac71359627b475e194fadaacb12612 Mon Sep 17 00:00:00 2001 From: owocado <24418520+owocado@users.noreply.github.com> Date: Sat, 21 Jun 2025 00:33:29 +0530 Subject: [PATCH 019/138] Return invites when deleting them via Invite.delete or Client.delete_invite --- discord/client.py | 5 +++-- discord/http.py | 2 +- discord/invite.py | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/discord/client.py b/discord/client.py index daf2b8855446..68422435bfa8 100644 --- a/discord/client.py +++ b/discord/client.py @@ -2550,7 +2550,7 @@ async def fetch_invite( ) return Invite.from_incomplete(state=self._connection, data=data) - async def delete_invite(self, invite: Union[Invite, str], /) -> None: + async def delete_invite(self, invite: Union[Invite, str], /) -> Invite: """|coro| Revokes an :class:`.Invite`, URL, or ID to an invite. @@ -2578,7 +2578,8 @@ async def delete_invite(self, invite: Union[Invite, str], /) -> None: """ resolved = utils.resolve_invite(invite) - await self.http.delete_invite(resolved.code) + data = await self.http.delete_invite(resolved.code) + return Invite.from_incomplete(state=self._connection, data=data) # Miscellaneous stuff diff --git a/discord/http.py b/discord/http.py index eb1dbbd06832..7d59c8bfb8b6 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1879,7 +1879,7 @@ def invites_from(self, guild_id: Snowflake) -> Response[List[invite.Invite]]: def invites_from_channel(self, channel_id: Snowflake) -> Response[List[invite.Invite]]: return self.request(Route('GET', '/channels/{channel_id}/invites', channel_id=channel_id)) - def delete_invite(self, invite_id: str, *, reason: Optional[str] = None) -> Response[None]: + def delete_invite(self, invite_id: str, *, reason: Optional[str] = None) -> Response[invite.Invite]: return self.request(Route('DELETE', '/invites/{invite_id}', invite_id=invite_id), reason=reason) # Role management diff --git a/discord/invite.py b/discord/invite.py index dd8cc954ac53..8c37bd232ca4 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -546,7 +546,7 @@ def set_scheduled_event(self, scheduled_event: Snowflake, /) -> Self: return self - async def delete(self, *, reason: Optional[str] = None) -> None: + async def delete(self, *, reason: Optional[str] = None) -> Self: """|coro| Revokes the instant invite. @@ -568,4 +568,5 @@ async def delete(self, *, reason: Optional[str] = None) -> None: Revoking the invite failed. """ - await self._state.http.delete_invite(self.code, reason=reason) + data = await self._state.http.delete_invite(self.code, reason=reason) + return self.from_incomplete(state=self._state, data=data) From f6e0f72498794591e2b87b8f85682823556f0c73 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Fri, 20 Jun 2025 21:04:21 +0200 Subject: [PATCH 020/138] Add ability to create a media-only forum channel --- discord/guild.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index 291363b1885c..2e834f9b2824 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -1800,6 +1800,7 @@ async def create_forum( category: Optional[CategoryChannel] = None, slowmode_delay: int = MISSING, nsfw: bool = MISSING, + media: bool = MISSING, overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] = MISSING, reason: Optional[str] = None, default_auto_archive_duration: int = MISSING, @@ -1862,12 +1863,17 @@ async def create_forum( .. versionadded:: 2.3 default_layout: :class:`ForumLayoutType` The default layout for posts in this forum. + This cannot be set if ``media`` is set to ``True``. .. versionadded:: 2.3 available_tags: Sequence[:class:`ForumTag`] The available tags for this forum channel. .. versionadded:: 2.1 + media: :class:`bool` + Whether to create a media forum channel. + + .. versionadded:: 2.6 Raises ------- @@ -1919,7 +1925,7 @@ async def create_forum( else: raise ValueError(f'default_reaction_emoji parameter must be either Emoji, PartialEmoji, or str') - if default_layout is not MISSING: + if not media and default_layout is not MISSING: if not isinstance(default_layout, ForumLayoutType): raise TypeError( f'default_layout parameter must be a ForumLayoutType not {default_layout.__class__.__name__}' @@ -1931,10 +1937,17 @@ async def create_forum( options['available_tags'] = [t.to_dict() for t in available_tags] data = await self._create_channel( - name=name, overwrites=overwrites, channel_type=ChannelType.forum, category=category, reason=reason, **options + name=name, + overwrites=overwrites, + channel_type=ChannelType.forum if not media else ChannelType.media, + category=category, + reason=reason, + **options, ) - channel = ForumChannel(state=self._state, guild=self, data=data) + channel = ForumChannel( + state=self._state, guild=self, data=data # pyright: ignore[reportArgumentType] # it's the correct data + ) # temporarily add to the cache self._channels[channel.id] = channel From 6af9de0c39bdc51e02c05d0c5bd93a77809939b3 Mon Sep 17 00:00:00 2001 From: Michael H Date: Fri, 20 Jun 2025 15:06:52 -0400 Subject: [PATCH 021/138] Ensure COPYING notice is included --- MANIFEST.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index e623df089b9d..8e93fd092a5d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,5 @@ include README.rst include LICENSE include requirements.txt -include discord/bin/*.dll +include discord/bin/* include discord/py.typed From 7fe1102841987698d9ca445b05badf950bdf6114 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Fri, 20 Jun 2025 21:07:23 +0200 Subject: [PATCH 022/138] Allow creating NSFW voice/stage channels --- discord/guild.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/discord/guild.py b/discord/guild.py index 2e834f9b2824..6b8e8814e4c3 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -1560,6 +1560,7 @@ async def create_voice_channel( rtc_region: Optional[str] = MISSING, video_quality_mode: VideoQualityMode = MISSING, overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] = MISSING, + nsfw: bool = MISSING, ) -> VoiceChannel: """|coro| @@ -1597,6 +1598,10 @@ async def create_voice_channel( The camera video quality for the voice channel's participants. .. versionadded:: 2.0 + nsfw: :class:`bool` + To mark the channel as NSFW or not. + + .. versionadded:: 2.6 reason: Optional[:class:`str`] The reason for creating this channel. Shows up on the audit log. @@ -1632,6 +1637,9 @@ async def create_voice_channel( raise TypeError('video_quality_mode must be of type VideoQualityMode') options['video_quality_mode'] = video_quality_mode.value + if nsfw is not MISSING: + options['nsfw'] = nsfw + data = await self._create_channel( name, overwrites=overwrites, channel_type=ChannelType.voice, category=category, reason=reason, **options ) @@ -1653,6 +1661,7 @@ async def create_stage_channel( rtc_region: Optional[str] = MISSING, video_quality_mode: VideoQualityMode = MISSING, overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] = MISSING, + nsfw: bool = MISSING, ) -> StageChannel: """|coro| @@ -1696,6 +1705,10 @@ async def create_stage_channel( The camera video quality for the voice channel's participants. .. versionadded:: 2.2 + nsfw: :class:`bool` + To mark the channel as NSFW or not. + + .. versionadded:: 2.6 reason: Optional[:class:`str`] The reason for creating this channel. Shows up on the audit log. @@ -1732,6 +1745,9 @@ async def create_stage_channel( raise TypeError('video_quality_mode must be of type VideoQualityMode') options['video_quality_mode'] = video_quality_mode.value + if nsfw is not MISSING: + options['nsfw'] = nsfw + data = await self._create_channel( name, overwrites=overwrites, channel_type=ChannelType.stage_voice, category=category, reason=reason, **options ) From e9f807e5ecee491e78f5e36092f0d818f2c7dd33 Mon Sep 17 00:00:00 2001 From: owocado <24418520+owocado@users.noreply.github.com> Date: Sat, 21 Jun 2025 00:50:20 +0530 Subject: [PATCH 023/138] Fix Thread.applied_tags for media channels --- discord/threads.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/threads.py b/discord/threads.py index 024b22506b04..0c8060193f87 100644 --- a/discord/threads.py +++ b/discord/threads.py @@ -272,12 +272,12 @@ def applied_tags(self) -> List[ForumTag]: .. versionadded:: 2.1 """ tags = [] - if self.parent is None or self.parent.type != ChannelType.forum: + if self.parent is None or self.parent.type not in (ChannelType.forum, ChannelType.media): return tags parent = self.parent for tag_id in self._applied_tags: - tag = parent.get_tag(tag_id) + tag = parent.get_tag(tag_id) # type: ignore # parent here will be ForumChannel instance if tag is not None: tags.append(tag) From a62b25c6c00fd8de060703eec0cd6e6599fe1291 Mon Sep 17 00:00:00 2001 From: owocado <24418520+owocado@users.noreply.github.com> Date: Sat, 21 Jun 2025 03:56:36 +0530 Subject: [PATCH 024/138] Add missing attributes in AppCommandChannel --- discord/app_commands/models.py | 69 +++++++++++++++++++++++++++++++++- discord/types/interactions.py | 14 +++++-- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/discord/app_commands/models.py b/discord/app_commands/models.py index dcf70d41e0a4..5851e7d8cce1 100644 --- a/discord/app_commands/models.py +++ b/discord/app_commands/models.py @@ -26,7 +26,7 @@ from datetime import datetime from .errors import MissingApplicationID -from ..flags import AppCommandContext, AppInstallationType +from ..flags import AppCommandContext, AppInstallationType, ChannelFlags from .translator import TranslationContextLocation, TranslationContext, locale_str, Translator from ..permissions import Permissions from ..enums import ( @@ -575,6 +575,35 @@ class AppCommandChannel(Hashable): the application command in that channel. guild_id: :class:`int` The guild ID this channel belongs to. + category_id: Optional[:class:`int`] + The category channel ID this channel belongs to, if applicable. + + .. versionadded:: 2.6 + topic: Optional[:class:`str`] + The channel's topic. ``None`` if it doesn't exist. + + .. versionadded:: 2.6 + position: :class:`int` + The position in the channel list. This is a number that starts at 0. e.g. the + top channel is position 0. + + .. versionadded:: 2.6 + last_message_id: Optional[:class:`int`] + The last message ID of the message sent to this channel. It may + *not* point to an existing or valid message. + + .. versionadded:: 2.6 + slowmode_delay: :class:`int` + The number of seconds a member must wait between sending messages + in this channel. A value of ``0`` denotes that it is disabled. + Bots and users with :attr:`~discord.Permissions.manage_channels` or + :attr:`~discord.Permissions.manage_messages` bypass slowmode. + + .. versionadded:: 2.6 + nsfw: :class:`bool` + If the channel is marked as "not safe for work" or "age restricted". + + .. versionadded:: 2.6 """ __slots__ = ( @@ -583,6 +612,14 @@ class AppCommandChannel(Hashable): 'name', 'permissions', 'guild_id', + 'topic', + 'nsfw', + 'position', + 'category_id', + 'slowmode_delay', + 'last_message_id', + '_last_pin', + '_flags', '_state', ) @@ -599,6 +636,14 @@ def __init__( self.type: ChannelType = try_enum(ChannelType, data['type']) self.name: str = data['name'] self.permissions: Permissions = Permissions(int(data['permissions'])) + self.topic: Optional[str] = data.get('topic') + self.position: int = data.get('position') or 0 + self.nsfw: bool = data.get('nsfw') or False + self.category_id: Optional[int] = _get_as_snowflake(data, 'parent_id') + self.slowmode_delay: int = data.get('rate_limit_per_user') or 0 + self.last_message_id: Optional[int] = _get_as_snowflake(data, 'last_message_id') + self._last_pin: Optional[datetime] = parse_time(data.get('last_pin_timestamp')) + self._flags: int = data.get('flags', 0) def __str__(self) -> str: return self.name @@ -611,6 +656,28 @@ def guild(self) -> Optional[Guild]: """Optional[:class:`~discord.Guild`]: The channel's guild, from cache, if found.""" return self._state._get_guild(self.guild_id) + @property + def flags(self) -> ChannelFlags: + """:class:`~discord.ChannelFlags`: The flags associated with this channel object. + + .. versionadded:: 2.6 + """ + return ChannelFlags._from_value(self._flags) + + def is_nsfw(self) -> bool: + """:class:`bool`: Checks if the channel is NSFW. + + .. versionadded:: 2.6 + """ + return self.nsfw + + def is_news(self) -> bool: + """:class:`bool`: Checks if the channel is a news channel. + + .. versionadded:: 2.6 + """ + return self.type == ChannelType.news + def resolve(self) -> Optional[GuildChannel]: """Resolves the application command channel to the appropriate channel from cache if found. diff --git a/discord/types/interactions.py b/discord/types/interactions.py index 3e814b49df6e..464f2445fd1c 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -24,12 +24,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, List, Literal, TypedDict, Union +from typing import TYPE_CHECKING, Dict, List, Literal, TypedDict, Union, Optional from typing_extensions import NotRequired -from .channel import ChannelTypeWithoutThread, ThreadMetadata, GuildChannel, InteractionDMChannel, GroupDMChannel +from .channel import ChannelTypeWithoutThread, GuildChannel, InteractionDMChannel, GroupDMChannel from .sku import Entitlement -from .threads import ThreadType +from .threads import ThreadType, ThreadMetadata from .member import Member from .message import Attachment from .role import Role @@ -64,6 +64,14 @@ class _BasePartialChannel(TypedDict): class PartialChannel(_BasePartialChannel): type: ChannelTypeWithoutThread + topic: NotRequired[str] + position: int + nsfw: bool + flags: int + rate_limit_per_user: int + parent_id: Optional[Snowflake] + last_message_id: Optional[Snowflake] + last_pin_timestamp: NotRequired[str] class PartialThread(_BasePartialChannel): From 20055e7cc1d631ce7fad10e21ed0feda49f1a58a Mon Sep 17 00:00:00 2001 From: dolfies Date: Wed, 25 Jun 2025 21:05:13 -0400 Subject: [PATCH 025/138] Fix calculation of hashed rate limit keys --- discord/http.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/discord/http.py b/discord/http.py index 7d59c8bfb8b6..f1f4ec58b793 100644 --- a/discord/http.py +++ b/discord/http.py @@ -673,14 +673,13 @@ async def request( _log.debug(fmt, route_key, bucket_hash, discord_hash) self._bucket_hashes[route_key] = discord_hash - recalculated_key = discord_hash + route.major_parameters - self._buckets[recalculated_key] = ratelimit + self._buckets[f'{discord_hash}:{route.major_parameters}'] = ratelimit self._buckets.pop(key, None) elif route_key not in self._bucket_hashes: fmt = '%s has found its initial rate limit bucket hash (%s).' _log.debug(fmt, route_key, discord_hash) self._bucket_hashes[route_key] = discord_hash - self._buckets[discord_hash + route.major_parameters] = ratelimit + self._buckets[f'{discord_hash}:{route.major_parameters}'] = ratelimit if has_ratelimit_headers: if response.status != 429: From 2175bd51c0d0c2817e69a708e507108f3bc902bd Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Mon, 30 Jun 2025 09:14:23 +0200 Subject: [PATCH 026/138] Fix voice connection issues and upgrade to voice v8 --- discord/gateway.py | 16 ++++++++++++++-- discord/voice_state.py | 40 +++++++++++++++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/discord/gateway.py b/discord/gateway.py index 44656df03633..a2c3da3d2d21 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -212,6 +212,9 @@ def ack(self) -> None: class VoiceKeepAliveHandler(KeepAliveHandler): + if TYPE_CHECKING: + ws: DiscordVoiceWebSocket + def __init__(self, *args: Any, **kwargs: Any) -> None: name: str = kwargs.pop('name', f'voice-keep-alive-handler:{id(self):#x}') super().__init__(*args, name=name, **kwargs) @@ -223,7 +226,10 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: def get_payload(self) -> Dict[str, Any]: return { 'op': self.ws.HEARTBEAT, - 'd': int(time.time() * 1000), + 'd': { + 't': int(time.time() * 1000), + 'seq_ack': self.ws.seq_ack, + }, } def ack(self) -> None: @@ -830,6 +836,8 @@ def __init__( self._keep_alive: Optional[VoiceKeepAliveHandler] = None self._close_code: Optional[int] = None self.secret_key: Optional[List[int]] = None + # defaulting to -1 + self.seq_ack: int = -1 if hook: self._hook = hook # type: ignore @@ -850,6 +858,7 @@ async def resume(self) -> None: 'token': state.token, 'server_id': str(state.server_id), 'session_id': state.session_id, + 'seq_ack': self.seq_ack, }, } await self.send_as_json(payload) @@ -874,14 +883,16 @@ async def from_connection_state( *, resume: bool = False, hook: Optional[Callable[..., Coroutine[Any, Any, Any]]] = None, + seq_ack: int = -1, ) -> Self: """Creates a voice websocket for the :class:`VoiceClient`.""" - gateway = f'wss://{state.endpoint}/?v=4' + gateway = f'wss://{state.endpoint}/?v=8' client = state.voice_client http = client._state.http socket = await http.ws_connect(gateway, compress=15) ws = cls(socket, loop=client.loop, hook=hook) ws.gateway = gateway + ws.seq_ack = seq_ack ws._connection = state ws._max_heartbeat_timeout = 60.0 ws.thread_id = threading.get_ident() @@ -934,6 +945,7 @@ async def received_message(self, msg: Dict[str, Any]) -> None: _log.debug('Voice websocket frame received: %s', msg) op = msg['op'] data = msg['d'] # According to Discord this key is always given + self.seq_ack = msg.get('seq', self.seq_ack) # this key could not be given if op == self.READY: await self.initial_connection(data) diff --git a/discord/voice_state.py b/discord/voice_state.py index 956f639b8e0a..d2cc0ebc183b 100644 --- a/discord/voice_state.py +++ b/discord/voice_state.py @@ -321,7 +321,7 @@ async def voice_server_update(self, data: VoiceServerUpdatePayload) -> None: ) return - self.endpoint, _, _ = endpoint.rpartition(':') + self.endpoint = endpoint if self.endpoint.startswith('wss://'): # Just in case, strip it off since we're going to add it later self.endpoint = self.endpoint[6:] @@ -574,7 +574,10 @@ async def _voice_disconnect(self) -> None: self._disconnected.clear() async def _connect_websocket(self, resume: bool) -> DiscordVoiceWebSocket: - ws = await DiscordVoiceWebSocket.from_connection_state(self, resume=resume, hook=self.hook) + seq_ack = -1 + if self.ws is not MISSING: + seq_ack = self.ws.seq_ack + ws = await DiscordVoiceWebSocket.from_connection_state(self, resume=resume, hook=self.hook, seq_ack=seq_ack) self.state = ConnectionFlowState.websocket_connected return ws @@ -603,15 +606,17 @@ async def _poll_voice_ws(self, reconnect: bool) -> None: # The following close codes are undocumented so I will document them here. # 1000 - normal closure (obviously) # 4014 - we were externally disconnected (voice channel deleted, we were moved, etc) - # 4015 - voice server has crashed - if exc.code in (1000, 4015): + # 4015 - voice server has crashed, we should resume + # 4021 - rate limited, we should not reconnect + # 4022 - call terminated, similar to 4014 + if exc.code == 1000: # Don't call disconnect a second time if the websocket closed from a disconnect call if not self._expecting_disconnect: _log.info('Disconnecting from voice normally, close code %d.', exc.code) await self.disconnect() break - if exc.code == 4014: + if exc.code in (4014, 4022): # We were disconnected by discord # This condition is a race between the main ws event and the voice ws closing if self._disconnected.is_set(): @@ -631,6 +636,31 @@ async def _poll_voice_ws(self, reconnect: bool) -> None: else: continue + if exc.code == 4021: + _log.warning('We are being ratelimited while trying to connect to voice. Disconnecting...') + if self.state is not ConnectionFlowState.disconnected: + await self.disconnect() + break + + if exc.code == 4015: + _log.info('Disconnected from voice, attempting a resume...') + try: + await self._connect( + reconnect=reconnect, + timeout=self.timeout, + self_deaf=(self.self_voice_state or self).self_deaf, + self_mute=(self.self_voice_state or self).self_mute, + resume=True, + ) + except asyncio.TimeoutError: + _log.info('Could not resume the voice connection... Disconnecting...') + if self.state is not ConnectionFlowState.disconnected: + await self.disconnect() + break + else: + _log.info('Successfully resumed voice connection') + continue + _log.debug('Not handling close code %s (%s)', exc.code, exc.reason or 'no reason') if not reconnect: From 59546a485184cc84fb006e6b2ef7a9e85a6fdc81 Mon Sep 17 00:00:00 2001 From: Joosemi02 <37875402+Joosemi02@users.noreply.github.com> Date: Thu, 3 Jul 2025 00:54:57 +0200 Subject: [PATCH 027/138] Add support for launch_activity interaction response --- discord/enums.py | 1 + discord/interactions.py | 46 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/discord/enums.py b/discord/enums.py index 7915bcb4b04b..71f755c1281a 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -621,6 +621,7 @@ class InteractionResponseType(Enum): autocomplete_result = 8 modal = 9 # for modals # premium_required = 10 (deprecated) + launch_activity = 12 class VideoQualityMode(Enum): diff --git a/discord/interactions.py b/discord/interactions.py index abe47efa2b7d..82b35e39228e 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -1296,6 +1296,52 @@ async def autocomplete(self, choices: Sequence[Choice[ChoiceT]]) -> None: self._response_type = InteractionResponseType.autocomplete_result + async def launch_activity(self) -> InteractionCallbackResponse[ClientT]: + """|coro| + + Responds to this interaction by launching the activity associated with the app. + Only available for apps with activities enabled. + + .. versionadded:: 2.6 + + Raises + ------- + HTTPException + Launching the activity failed. + InteractionResponded + This interaction has already been responded to before. + + Returns + ------- + :class:`InteractionCallbackResponse` + The interaction callback data. + """ + if self._response_type: + raise InteractionResponded(self._parent) + + parent = self._parent + + adapter = async_context.get() + http = parent._state.http + + params = interaction_response_params(InteractionResponseType.launch_activity.value) + response = await adapter.create_interaction_response( + parent.id, + parent.token, + session=parent._session, + proxy=http.proxy, + proxy_auth=http.proxy_auth, + params=params, + ) + self._response_type = InteractionResponseType.launch_activity + + return InteractionCallbackResponse( + data=response, + parent=self._parent, + state=self._parent._state, + type=self._response_type, + ) + class _InteractionMessageState: __slots__ = ('_parent', '_interaction') From 774b934f7432ea7274befb3580fd51cdc8cf366e Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Thu, 3 Jul 2025 00:59:40 +0200 Subject: [PATCH 028/138] Add support for guest invites --- discord/abc.py | 12 +++++++++ discord/audit_logs.py | 11 ++++++-- discord/flags.py | 57 +++++++++++++++++++++++++++++++++++++++++ discord/http.py | 4 +++ discord/invite.py | 11 ++++++++ discord/member.py | 3 ++- discord/types/invite.py | 2 ++ discord/types/member.py | 2 +- docs/api.rst | 16 +++++++++--- 9 files changed, 111 insertions(+), 7 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 692472f8fa8c..713398a7db4d 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -60,6 +60,7 @@ from .voice_client import VoiceClient, VoiceProtocol from .sticker import GuildSticker, StickerItem from . import utils +from .flags import InviteFlags __all__ = ( 'Snowflake', @@ -1257,6 +1258,7 @@ async def create_invite( target_type: Optional[InviteTarget] = None, target_user: Optional[User] = None, target_application_id: Optional[int] = None, + guest: bool = False, ) -> Invite: """|coro| @@ -1295,6 +1297,10 @@ async def create_invite( The id of the embedded application for the invite, required if ``target_type`` is :attr:`.InviteTarget.embedded_application`. .. versionadded:: 2.0 + guest: :class:`bool` + Whether the invite is a guest invite. + + .. versionadded:: 2.6 Raises ------- @@ -1312,6 +1318,11 @@ async def create_invite( if target_type is InviteTarget.unknown: raise ValueError('Cannot create invite with an unknown target type') + flags: Optional[InviteFlags] = None + if guest: + flags = InviteFlags._from_value(0) + flags.guest = True + data = await self._state.http.create_invite( self.id, reason=reason, @@ -1322,6 +1333,7 @@ async def create_invite( target_type=target_type.value if target_type else None, target_user_id=target_user.id if target_user else None, target_application_id=target_application_id, + flags=flags.value if flags else None, ) return Invite.from_incomplete(data=data, state=self._state) diff --git a/discord/audit_logs.py b/discord/audit_logs.py index af67855d4584..89577769fa9c 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -145,8 +145,8 @@ def _transform_applied_forum_tags(entry: AuditLogEntry, data: List[Snowflake]) - return [Object(id=tag_id, type=ForumTag) for tag_id in data] -def _transform_overloaded_flags(entry: AuditLogEntry, data: int) -> Union[int, flags.ChannelFlags]: - # The `flags` key is definitely overloaded. Right now it's for channels and threads but +def _transform_overloaded_flags(entry: AuditLogEntry, data: int) -> Union[int, flags.ChannelFlags, flags.InviteFlags]: + # The `flags` key is definitely overloaded. Right now it's for channels, threads and invites but # I am aware of `member.flags` and `user.flags` existing. However, this does not impact audit logs # at the moment but better safe than sorry. channel_audit_log_types = ( @@ -157,9 +157,16 @@ def _transform_overloaded_flags(entry: AuditLogEntry, data: int) -> Union[int, f enums.AuditLogAction.thread_update, enums.AuditLogAction.thread_delete, ) + invite_audit_log_types = ( + enums.AuditLogAction.invite_create, + enums.AuditLogAction.invite_update, + enums.AuditLogAction.invite_delete, + ) if entry.action in channel_audit_log_types: return flags.ChannelFlags._from_value(data) + elif entry.action in invite_audit_log_types: + return flags.InviteFlags._from_value(data) return data diff --git a/discord/flags.py b/discord/flags.py index 20f8c5470fee..59a4909b8557 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -64,6 +64,7 @@ 'AppInstallationType', 'SKUFlags', 'EmbedFlags', + 'InviteFlags', ) BF = TypeVar('BF', bound='BaseFlags') @@ -2397,3 +2398,59 @@ def content_inventory_entry(self): longer displayed. """ return 1 << 5 + + +class InviteFlags(BaseFlags): + r"""Wraps up the Discord Invite flags + + .. versionadded:: 2.6 + + .. container:: operations + + .. describe:: x == y + + Checks if two InviteFlags are equal. + + .. describe:: x != y + + Checks if two InviteFlags are not equal. + + .. describe:: x | y, x |= y + + Returns a InviteFlags instance with all enabled flags from + both x and y. + + .. describe:: x ^ y, x ^= y + + Returns a InviteFlags instance with only flags enabled on + only one of x or y, not on both. + + .. describe:: ~x + + Returns a InviteFlags instance with all flags inverted from x. + + .. describe:: hash(x) + + Returns the flag's hash. + + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + .. describe:: bool(b) + + Returns whether any flag is set to ``True``. + + Attributes + ---------- + value: :class:`int` + The raw value. You should query flags via the properties + rather than using this raw value. + """ + + @flag_value + def guest(self): + """:class:`bool`: Returns ``True`` if this is a guest invite for a voice channel.""" + return 1 << 0 diff --git a/discord/http.py b/discord/http.py index f1f4ec58b793..71912f71b23e 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1834,6 +1834,7 @@ def create_invite( target_type: Optional[invite.InviteTargetType] = None, target_user_id: Optional[Snowflake] = None, target_application_id: Optional[Snowflake] = None, + flags: Optional[int] = None, ) -> Response[invite.Invite]: r = Route('POST', '/channels/{channel_id}/invites', channel_id=channel_id) payload = { @@ -1852,6 +1853,9 @@ def create_invite( if target_application_id: payload['target_application_id'] = str(target_application_id) + if flags: + payload['flags'] = flags + return self.request(r, reason=reason, json=payload) def get_invite( diff --git a/discord/invite.py b/discord/invite.py index 8c37bd232ca4..362f976931d4 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -32,6 +32,7 @@ from .enums import ChannelType, NSFWLevel, VerificationLevel, InviteTarget, InviteType, try_enum from .appinfo import PartialAppInfo from .scheduled_event import ScheduledEvent +from .flags import InviteFlags __all__ = ( 'PartialInviteChannel', @@ -379,6 +380,7 @@ class Invite(Hashable): 'scheduled_event', 'scheduled_event_id', 'type', + '_flags', ) BASE = 'https://discord.gg' @@ -432,6 +434,7 @@ def __init__( else None ) self.scheduled_event_id: Optional[int] = self.scheduled_event.id if self.scheduled_event else None + self._flags: int = data.get('flags', 0) @classmethod def from_incomplete(cls, *, state: ConnectionState, data: InvitePayload) -> Self: @@ -523,6 +526,14 @@ def url(self) -> str: url += '?event=' + str(self.scheduled_event_id) return url + @property + def flags(self) -> InviteFlags: + """:class:`InviteFlags`: Returns the flags for this invite. + + .. versionadded:: 2.6 + """ + return InviteFlags._from_value(self._flags) + def set_scheduled_event(self, scheduled_event: Snowflake, /) -> Self: """Sets the scheduled event for this invite. diff --git a/discord/member.py b/discord/member.py index 6af1571f4d34..ed52600ddbfa 100644 --- a/discord/member.py +++ b/discord/member.py @@ -238,7 +238,8 @@ class Member(discord.abc.Messageable, _UserTag): ---------- joined_at: Optional[:class:`datetime.datetime`] An aware datetime object that specifies the date and time in UTC that the member joined the guild. - If the member left and rejoined the guild, this will be the latest date. In certain cases, this can be ``None``. + If the member left and rejoined the guild, this will be the latest date. + This can be ``None``, such as when the member is a guest. activities: Tuple[Union[:class:`BaseActivity`, :class:`Spotify`]] The activities that the user is currently doing. diff --git a/discord/types/invite.py b/discord/types/invite.py index f5f00078e950..47c9729941b3 100644 --- a/discord/types/invite.py +++ b/discord/types/invite.py @@ -65,6 +65,7 @@ class Invite(IncompleteInvite, total=False): target_application: PartialAppInfo guild_scheduled_event: GuildScheduledEvent type: InviteType + flags: NotRequired[int] class InviteWithCounts(Invite, _GuildPreviewUnique): @@ -84,6 +85,7 @@ class GatewayInviteCreate(TypedDict): target_type: NotRequired[InviteTargetType] target_user: NotRequired[PartialUser] target_application: NotRequired[PartialAppInfo] + flags: NotRequired[int] class GatewayInviteDelete(TypedDict): diff --git a/discord/types/member.py b/discord/types/member.py index 88fb619fd398..576ef421d2ab 100644 --- a/discord/types/member.py +++ b/discord/types/member.py @@ -34,7 +34,7 @@ class Nickname(TypedDict): class PartialMember(TypedDict): roles: SnowflakeList - joined_at: str + joined_at: Optional[str] # null if guest deaf: bool mute: bool flags: int diff --git a/docs/api.rst b/docs/api.rst index dda5553b7074..dc6775ec6703 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -2517,6 +2517,7 @@ of :class:`enum.Enum`. - :attr:`~AuditLogDiff.channel` - :attr:`~AuditLogDiff.uses` - :attr:`~AuditLogDiff.max_uses` + - :attr:`~AuditLogDiff.flags` .. attribute:: invite_update @@ -2541,6 +2542,7 @@ of :class:`enum.Enum`. - :attr:`~AuditLogDiff.channel` - :attr:`~AuditLogDiff.uses` - :attr:`~AuditLogDiff.max_uses` + - :attr:`~AuditLogDiff.flags` .. attribute:: webhook_create @@ -4552,11 +4554,11 @@ AuditLogDiff .. attribute:: flags - The channel flags associated with this thread or forum post. + The flags associated with this thread, forum post or invite. - See also :attr:`ForumChannel.flags` and :attr:`Thread.flags` + See also :attr:`ForumChannel.flags`, :attr:`Thread.flags` and :attr:`Invite.flags` - :type: :class:`ChannelFlags` + :type: Union[:class:`ChannelFlags`, :class:`InviteFlags`] .. attribute:: default_thread_slowmode_delay @@ -5734,6 +5736,14 @@ EmbedFlags .. autoclass:: EmbedFlags() :members: +InviteFlags +~~~~~~~~~~~~~~~~ + +.. attributetable:: InviteFlags + +.. autoclass:: InviteFlags() + :members: + ForumTag ~~~~~~~~~ From 2502a783cb5b54c1179d7c1f60dc4283f923b98e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=8F=A1=E8=8A=B1=E6=B0=B4=E6=9C=88?= Date: Tue, 8 Jul 2025 00:06:28 -0300 Subject: [PATCH 029/138] Add BCP47 unicode tags for Locale enum Co-authored-by: Danny <1695103+Rapptz@users.noreply.github.com> --- discord/enums.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/discord/enums.py b/discord/enums.py index 71f755c1281a..acc78012014f 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations import types @@ -694,6 +695,42 @@ class MFALevel(Enum, comparable=True): require_2fa = 1 +_UNICODE_LANG_MAP: Dict[str, str] = { + 'bg': 'bg-BG', + 'zh-CN': 'zh-CN', + 'zh-TW': 'zh-TW', + 'hr': 'hr-HR', + 'cs': 'cs-CZ', + 'da': 'da-DK', + 'nl': 'nl-NL', + 'en-US': 'en-US', + 'en-GB': 'en-GB', + 'fi': 'fi-FI', + 'fr': 'fr-FR', + 'de': 'de-DE', + 'el': 'el-GR', + 'hi': 'hi-IN', + 'hu': 'hu-HU', + 'id': 'id-ID', + 'it': 'it-IT', + 'ja': 'ja-JP', + 'ko': 'ko-KR', + 'lt': 'lt-LT', + 'no': 'no-NO', + 'pl': 'pl-PL', + 'pt-BR': 'pt-BR', + 'ro': 'ro-RO', + 'ru': 'ru-RU', + 'es-ES': 'es-ES', + 'es-419': 'es-419', + 'sv-SE': 'sv-SE', + 'th': 'th-TH', + 'tr': 'tr-TR', + 'uk': 'uk-UA', + 'vi': 'vi-VN', +} + + class Locale(Enum): american_english = 'en-US' british_english = 'en-GB' @@ -731,6 +768,17 @@ class Locale(Enum): def __str__(self) -> str: return self.value + @property + def language_code(self) -> str: + """:class:`str`: Returns the locale's BCP 47 language code in the format of ``language-COUNTRY``. + + This is derived from a predefined mapping based on Discord's supported locales. + If no mapping exists for the current locale, this returns the raw locale value as a fallback. + + .. versionadded:: 2.6 + """ + return _UNICODE_LANG_MAP.get(self.value, self.value) + E = TypeVar('E', bound='Enum') From cb7300990f656c0964ea48115354f9416e96dcd1 Mon Sep 17 00:00:00 2001 From: Mak <99765898+makerze@users.noreply.github.com> Date: Tue, 8 Jul 2025 04:07:56 +0100 Subject: [PATCH 030/138] Add role parameters to support new gradient and holographic roles Co-authored-by: dolfies Co-authored-by: DA344 <108473820+DA-344@users.noreply.github.com> Co-authored-by: Danny <1695103+Rapptz@users.noreply.github.com> --- discord/guild.py | 48 +++++++++++++++++++++++-- discord/http.py | 2 +- discord/role.py | 82 +++++++++++++++++++++++++++++++++++++++--- discord/types/guild.py | 1 + discord/types/role.py | 7 ++++ 5 files changed, 132 insertions(+), 8 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index 6b8e8814e4c3..b03dbbea6445 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -3648,6 +3648,8 @@ async def create_role( hoist: bool = ..., display_icon: Union[bytes, str] = MISSING, mentionable: bool = ..., + secondary_colour: Optional[Union[Colour, int]] = ..., + tertiary_colour: Optional[Union[Colour, int]] = ..., ) -> Role: ... @@ -3662,6 +3664,8 @@ async def create_role( hoist: bool = ..., display_icon: Union[bytes, str] = MISSING, mentionable: bool = ..., + secondary_color: Optional[Union[Colour, int]] = ..., + tertiary_color: Optional[Union[Colour, int]] = ..., ) -> Role: ... @@ -3676,6 +3680,10 @@ async def create_role( display_icon: Union[bytes, str] = MISSING, mentionable: bool = MISSING, reason: Optional[str] = None, + secondary_color: Optional[Union[Colour, int]] = MISSING, + tertiary_color: Optional[Union[Colour, int]] = MISSING, + secondary_colour: Optional[Union[Colour, int]] = MISSING, + tertiary_colour: Optional[Union[Colour, int]] = MISSING, ) -> Role: """|coro| @@ -3695,6 +3703,10 @@ async def create_role( This function will now raise :exc:`TypeError` instead of ``InvalidArgument``. + .. versionchanged:: 2.6 + The ``colour`` and ``color`` parameters now set the role's primary color. + + Parameters ----------- name: :class:`str` @@ -3704,6 +3716,15 @@ async def create_role( colour: Union[:class:`Colour`, :class:`int`] The colour for the role. Defaults to :meth:`Colour.default`. This is aliased to ``color`` as well. + secondary_colour: Optional[Union[:class:`Colour`, :class:`int`]] + The secondary colour for the role. + + .. versionadded:: 2.6 + tertiary_colour: Optional[Union[:class:`Colour`, :class:`int`]] + The tertiary colour for the role. Can only be used for the holographic role preset, + which is ``(11127295, 16759788, 16761760)`` + + .. versionadded:: 2.6 hoist: :class:`bool` Indicates if the role should be shown separately in the member list. Defaults to ``False``. @@ -3738,11 +3759,34 @@ async def create_role( else: fields['permissions'] = '0' + colours: Dict[str, Any] = {} + actual_colour = colour or color or Colour.default() if isinstance(actual_colour, int): - fields['color'] = actual_colour + colours['primary_color'] = actual_colour else: - fields['color'] = actual_colour.value + colours['primary_color'] = actual_colour.value + + actual_secondary_colour = secondary_colour or secondary_color + actual_tertiary_colour = tertiary_colour or tertiary_color + + if actual_secondary_colour is not MISSING: + if actual_secondary_colour is None: + colours['secondary_color'] = None + elif isinstance(actual_secondary_colour, int): + colours['secondary_color'] = actual_secondary_colour + else: + colours['secondary_color'] = actual_secondary_colour.value + + if actual_tertiary_colour is not MISSING: + if actual_tertiary_colour is None: + colours['tertiary_color'] = None + elif isinstance(actual_tertiary_colour, int): + colours['tertiary_color'] = actual_tertiary_colour + else: + colours['tertiary_color'] = actual_tertiary_colour.value + + fields['colors'] = colours if hoist is not MISSING: fields['hoist'] = hoist diff --git a/discord/http.py b/discord/http.py index 71912f71b23e..02fd1e136db4 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1897,7 +1897,7 @@ def edit_role( self, guild_id: Snowflake, role_id: Snowflake, *, reason: Optional[str] = None, **fields: Any ) -> Response[role.Role]: r = Route('PATCH', '/guilds/{guild_id}/roles/{role_id}', guild_id=guild_id, role_id=role_id) - valid_keys = ('name', 'permissions', 'color', 'hoist', 'icon', 'unicode_emoji', 'mentionable') + valid_keys = ('name', 'permissions', 'color', 'hoist', 'icon', 'unicode_emoji', 'mentionable', 'colors') payload = {k: v for k, v in fields.items() if k in valid_keys} return self.request(r, json=payload, reason=reason) diff --git a/discord/role.py b/discord/role.py index d7fe1e08bbe2..acb112519516 100644 --- a/discord/role.py +++ b/discord/role.py @@ -222,6 +222,8 @@ class Role(Hashable): 'tags', '_flags', '_state', + '_secondary_colour', + '_tertiary_colour', ) def __init__(self, *, guild: Guild, state: ConnectionState, data: RolePayload): @@ -273,10 +275,11 @@ def __ge__(self, other: object) -> bool: return not r def _update(self, data: RolePayload): + colors = data.get('colors', {}) self.name: str = data['name'] self._permissions: int = int(data.get('permissions', 0)) self.position: int = data.get('position', 0) - self._colour: int = data.get('color', 0) + self._colour: int = colors.get('primary_color', 0) self.hoist: bool = data.get('hoist', False) self._icon: Optional[str] = data.get('icon') self.unicode_emoji: Optional[str] = data.get('unicode_emoji') @@ -284,6 +287,8 @@ def _update(self, data: RolePayload): self.mentionable: bool = data.get('mentionable', False) self.tags: Optional[RoleTags] self._flags: int = data.get('flags', 0) + self._secondary_colour = colors.get('secondary_color', None) + self._tertiary_colour = colors.get('tertiary_color', None) try: self.tags = RoleTags(data['tags']) # pyright: ignore[reportTypedDictNotRequiredAccess] @@ -323,6 +328,34 @@ def is_assignable(self) -> bool: me = self.guild.me return not self.is_default() and not self.managed and (me.top_role > self or me.id == self.guild.owner_id) + @property + def secondary_colour(self) -> Optional[Colour]: + """Optional[:class:`Colour`]: The role's secondary colour. + .. versionadded:: 2.6 + """ + return Colour(self._secondary_colour) if self._secondary_colour is not None else None + + @property + def secondary_color(self) -> Optional[Colour]: + """Optional[:class:`Colour`]: Alias for :attr:`secondary_colour`. + .. versionadded:: 2.6 + """ + return self.secondary_colour + + @property + def tertiary_colour(self) -> Optional[Colour]: + """Optional[:class:`Colour`]: The role's tertiary colour. + .. versionadded:: 2.6 + """ + return Colour(self._tertiary_colour) if self._tertiary_colour is not None else None + + @property + def tertiary_color(self) -> Optional[Colour]: + """Optional[:class:`Colour`]: Alias for :attr:`tertiary_colour`. + .. versionadded:: 2.6 + """ + return self.tertiary_colour + @property def permissions(self) -> Permissions: """:class:`Permissions`: Returns the role's permissions.""" @@ -330,12 +363,12 @@ def permissions(self) -> Permissions: @property def colour(self) -> Colour: - """:class:`Colour`: Returns the role colour. An alias exists under ``color``.""" + """:class:`Colour`: Returns the role's primary colour. An alias exists under ``color``.""" return Colour(self._colour) @property def color(self) -> Colour: - """:class:`Colour`: Returns the role color. An alias exists under ``colour``.""" + """:class:`Colour`: Returns the role's primary colour. An alias exists under ``colour``.""" return self.colour @property @@ -425,6 +458,10 @@ async def edit( mentionable: bool = MISSING, position: int = MISSING, reason: Optional[str] = MISSING, + secondary_color: Optional[Union[Colour, int]] = MISSING, + tertiary_color: Optional[Union[Colour, int]] = MISSING, + secondary_colour: Optional[Union[Colour, int]] = MISSING, + tertiary_colour: Optional[Union[Colour, int]] = MISSING, ) -> Optional[Role]: """|coro| @@ -447,6 +484,9 @@ async def edit( This function will now raise :exc:`ValueError` instead of ``InvalidArgument``. + .. versionchanged:: 2.6 + The ``colour`` and ``color`` parameters now set the role's primary color. + Parameters ----------- name: :class:`str` @@ -455,6 +495,15 @@ async def edit( The new permissions to change to. colour: Union[:class:`Colour`, :class:`int`] The new colour to change to. (aliased to color as well) + secondary_colour: Optional[Union[:class:`Colour`, :class:`int`]] + The new secondary colour for the role. + + .. versionadded:: 2.6 + tertiary_colour: Optional[Union[:class:`Colour`, :class:`int`]] + The new tertiary colour for the role. Can only be used for the holographic role preset, + which is ``(11127295, 16759788, 16761760)`` + + .. versionadded:: 2.6 hoist: :class:`bool` Indicates if the role should be shown separately in the member list. display_icon: Optional[Union[:class:`bytes`, :class:`str`]] @@ -490,14 +539,17 @@ async def edit( await self._move(position, reason=reason) payload: Dict[str, Any] = {} + + colours: Dict[str, Any] = {} + if color is not MISSING: colour = color if colour is not MISSING: if isinstance(colour, int): - payload['color'] = colour + colours['primary_color'] = colour else: - payload['color'] = colour.value + colours['primary_color'] = colour.value if name is not MISSING: payload['name'] = name @@ -519,6 +571,26 @@ async def edit( if mentionable is not MISSING: payload['mentionable'] = mentionable + actual_secondary_colour = secondary_colour or secondary_color + actual_tertiary_colour = tertiary_colour or tertiary_color + + if actual_secondary_colour is not MISSING: + if actual_secondary_colour is None: + colours['secondary_color'] = None + elif isinstance(actual_secondary_colour, int): + colours['secondary_color'] = actual_secondary_colour + else: + colours['secondary_color'] = actual_secondary_colour.value + if actual_tertiary_colour is not MISSING: + if actual_tertiary_colour is None: + colours['tertiary_color'] = None + elif isinstance(actual_tertiary_colour, int): + colours['tertiary_color'] = actual_tertiary_colour + else: + colours['tertiary_color'] = actual_tertiary_colour.value + + if colours: + payload['colors'] = colours data = await self._state.http.edit_role(self.guild.id, self.id, reason=reason, **payload) return Role(guild=self.guild, data=data, state=self._state) diff --git a/discord/types/guild.py b/discord/types/guild.py index 7ac90b89ea53..0e328fed23f5 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -90,6 +90,7 @@ class IncidentData(TypedDict): 'VERIFIED', 'VIP_REGIONS', 'WELCOME_SCREEN_ENABLED', + 'ENHANCED_ROLE_COLORS', 'RAID_ALERTS_DISABLED', 'SOUNDBOARD', 'MORE_SOUNDBOARD', diff --git a/discord/types/role.py b/discord/types/role.py index d32de88032de..dabd1c1cfc30 100644 --- a/discord/types/role.py +++ b/discord/types/role.py @@ -30,10 +30,17 @@ from .snowflake import Snowflake +class RoleColours(TypedDict): + primary_color: int + secondary_color: Optional[int] + tertiary_color: Optional[int] + + class Role(TypedDict): id: Snowflake name: str color: int + colors: RoleColours hoist: bool position: int permissions: str From 66922cc2d15417d2ce900f04987c2f361537ac6d Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 9 Jul 2025 03:15:07 +0200 Subject: [PATCH 031/138] Fix documentation on Role secondary and tertiary colours Fix .. versionadded:: strings on Role.secondary_ and Role.tertiary_ colours --- discord/role.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/discord/role.py b/discord/role.py index acb112519516..18b02405946a 100644 --- a/discord/role.py +++ b/discord/role.py @@ -331,6 +331,7 @@ def is_assignable(self) -> bool: @property def secondary_colour(self) -> Optional[Colour]: """Optional[:class:`Colour`]: The role's secondary colour. + .. versionadded:: 2.6 """ return Colour(self._secondary_colour) if self._secondary_colour is not None else None @@ -338,6 +339,7 @@ def secondary_colour(self) -> Optional[Colour]: @property def secondary_color(self) -> Optional[Colour]: """Optional[:class:`Colour`]: Alias for :attr:`secondary_colour`. + .. versionadded:: 2.6 """ return self.secondary_colour @@ -345,6 +347,7 @@ def secondary_color(self) -> Optional[Colour]: @property def tertiary_colour(self) -> Optional[Colour]: """Optional[:class:`Colour`]: The role's tertiary colour. + .. versionadded:: 2.6 """ return Colour(self._tertiary_colour) if self._tertiary_colour is not None else None @@ -352,6 +355,7 @@ def tertiary_colour(self) -> Optional[Colour]: @property def tertiary_color(self) -> Optional[Colour]: """Optional[:class:`Colour`]: Alias for :attr:`tertiary_colour`. + .. versionadded:: 2.6 """ return self.tertiary_colour From b1be7dea7445f3b6e8a86d210897c796cc4389e7 Mon Sep 17 00:00:00 2001 From: El Laggron Date: Wed, 9 Jul 2025 03:16:39 +0200 Subject: [PATCH 032/138] Handle empty guild_ids list for app_commands --- discord/app_commands/commands.py | 17 ++++++++++++----- discord/ext/commands/hybrid.py | 6 +++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/discord/app_commands/commands.py b/discord/app_commands/commands.py index d5b8d93b27e5..1496a82bba51 100644 --- a/discord/app_commands/commands.py +++ b/discord/app_commands/commands.py @@ -686,9 +686,9 @@ def __init__( self._params: Dict[str, CommandParameter] = _extract_parameters_from_callback(callback, callback.__globals__) self.checks: List[Check] = getattr(callback, '__discord_app_commands_checks__', []) - self._guild_ids: Optional[List[int]] = guild_ids or getattr( - callback, '__discord_app_commands_default_guilds__', None - ) + self._guild_ids: Optional[List[int]] = guild_ids + if self._guild_ids is None: + self._guild_ids = getattr(callback, '__discord_app_commands_default_guilds__', None) self.default_permissions: Optional[Permissions] = getattr( callback, '__discord_app_commands_default_permissions__', None ) @@ -1249,7 +1249,9 @@ def __init__( self._param_name = param self._annotation = annotation self.module: Optional[str] = callback.__module__ - self._guild_ids = guild_ids or getattr(callback, '__discord_app_commands_default_guilds__', None) + self._guild_ids = guild_ids + if self._guild_ids is None: + self._guild_ids = getattr(callback, '__discord_app_commands_default_guilds__', None) self.on_error: Optional[UnboundError] = None self.default_permissions: Optional[Permissions] = getattr( callback, '__discord_app_commands_default_permissions__', None @@ -1586,7 +1588,9 @@ def __init__( self._attr: Optional[str] = None self._owner_cls: Optional[Type[Any]] = None - self._guild_ids: Optional[List[int]] = guild_ids or getattr(cls, '__discord_app_commands_default_guilds__', None) + self._guild_ids: Optional[List[int]] = guild_ids + if self._guild_ids is None: + self._guild_ids = getattr(cls, '__discord_app_commands_default_guilds__', None) if default_permissions is MISSING: if cls.__discord_app_commands_default_permissions__ is MISSING: @@ -2366,6 +2370,9 @@ def guilds(*guild_ids: Union[Snowflake, int]) -> Callable[[T], T]: specified by this decorator become the default guilds that it's added to rather than being a global command. + If no arguments are given, then the command will not be synced anywhere. This may + be modified later using the :meth:`CommandTree.add_command` method. + .. note:: Due to an implementation quirk and Python limitation, if this is used in conjunction diff --git a/discord/ext/commands/hybrid.py b/discord/ext/commands/hybrid.py index 0857003fad90..99b537ca1495 100644 --- a/discord/ext/commands/hybrid.py +++ b/discord/ext/commands/hybrid.py @@ -656,9 +656,9 @@ def __init__( self.fallback_locale: Optional[app_commands.locale_str] = fallback_locale if self.with_app_command: - guild_ids = attrs.pop('guild_ids', None) or getattr( - self.callback, '__discord_app_commands_default_guilds__', None - ) + guild_ids = attrs.pop('guild_ids', None) + if guild_ids is None: + guild_ids = getattr(self.callback, '__discord_app_commands_default_guilds__', None) guild_only = getattr(self.callback, '__discord_app_commands_guild_only__', False) default_permissions = getattr(self.callback, '__discord_app_commands_default_permissions__', None) nsfw = getattr(self.callback, '__discord_app_commands_is_nsfw__', False) From a00510988a204517f4777f1231407de3db726ae5 Mon Sep 17 00:00:00 2001 From: z03h <7235242+z03h@users.noreply.github.com> Date: Wed, 9 Jul 2025 13:56:38 -0400 Subject: [PATCH 033/138] Support new role colours in audit log --- discord/audit_logs.py | 18 +++++++++++++++++- discord/types/audit_log.py | 9 ++++++++- docs/api.rst | 34 +++++++++++++++++++++++++++++----- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/discord/audit_logs.py b/discord/audit_logs.py index 89577769fa9c..b781dcf80c08 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -69,7 +69,7 @@ DefaultReaction as DefaultReactionPayload, ) from .types.invite import Invite as InvitePayload - from .types.role import Role as RolePayload + from .types.role import Role as RolePayload, RoleColours from .types.snowflake import Snowflake from .types.command import ApplicationCommandPermissions from .types.automod import AutoModerationAction @@ -407,6 +407,12 @@ def __init__(self, entry: AuditLogEntry, data: List[AuditLogChangePayload]): self._handle_trigger_attr_update(self.after, self.before, entry, trigger_attr, elem['new_value']) # type: ignore continue + # special case for colors to set secondary and tertiary colos/colour attributes + if attr == 'colors': + self._handle_colours(self.before, elem['old_value']) # type: ignore # should be a RoleColours dict + self._handle_colours(self.after, elem['new_value']) # type: ignore # should be a RoleColours dict + continue + try: key, transformer = self.TRANSFORMERS[attr] except (ValueError, KeyError): @@ -539,6 +545,16 @@ def _handle_trigger_attr_update( except (AttributeError, TypeError): pass + def _handle_colours(self, diff: AuditLogDiff, colours: RoleColours): + # handle colours to multiple colour attributes + diff.color = diff.colour = Colour(colours['primary_color']) + + secondary_colour = colours['secondary_color'] + tertiary_colour = colours['tertiary_color'] + + diff.secondary_color = diff.secondary_colour = Colour(secondary_colour) if secondary_colour is not None else None + diff.tertiary_color = diff.tertiary_colour = Colour(tertiary_colour) if tertiary_colour is not None else None + def _create_trigger(self, diff: AuditLogDiff, entry: AuditLogEntry) -> AutoModTrigger: # check if trigger has already been created if not hasattr(diff, 'trigger'): diff --git a/discord/types/audit_log.py b/discord/types/audit_log.py index 2c37542fddc7..c9d3056959af 100644 --- a/discord/types/audit_log.py +++ b/discord/types/audit_log.py @@ -33,7 +33,7 @@ from .user import User from .scheduled_event import EntityType, EventStatus, GuildScheduledEvent from .snowflake import Snowflake -from .role import Role +from .role import Role, RoleColours from .channel import ChannelType, DefaultReaction, PrivacyLevel, VideoQualityMode, PermissionOverwrite, ForumTag from .threads import Thread from .command import ApplicationCommand, ApplicationCommandPermissions @@ -297,6 +297,12 @@ class _AuditLogChange_TriggerMetadata(TypedDict): old_value: Optional[AutoModerationTriggerMetadata] +class _AuditLogChange_RoleColours(TypedDict): + key: Literal['colors'] + new_value: RoleColours + old_value: RoleColours + + AuditLogChange = Union[ _AuditLogChange_Str, _AuditLogChange_AssetHash, @@ -321,6 +327,7 @@ class _AuditLogChange_TriggerMetadata(TypedDict): _AuditLogChange_AvailableTags, _AuditLogChange_DefaultReactionEmoji, _AuditLogChange_TriggerMetadata, + _AuditLogChange_RoleColours, ] diff --git a/docs/api.rst b/docs/api.rst index dc6775ec6703..c7d9e351f503 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -927,25 +927,25 @@ Members .. function:: on_raw_presence_update(payload) Called when a :class:`Member` updates their presence. - + This requires :attr:`Intents.presences` to be enabled. - Unlike :func:`on_presence_update`, when enabled, this is called regardless of the state of internal guild + Unlike :func:`on_presence_update`, when enabled, this is called regardless of the state of internal guild and member caches, and **does not** provide a comparison between the previous and updated states of the :class:`Member`. .. important:: - By default, this event is only dispatched when :attr:`Intents.presences` is enabled **and** :attr:`Intents.members` + By default, this event is only dispatched when :attr:`Intents.presences` is enabled **and** :attr:`Intents.members` is disabled. You can manually override this behaviour by setting the **enable_raw_presences** flag in the :class:`Client`, however :attr:`Intents.presences` is always required for this event to work. - + .. versionadded:: 2.5 :param payload: The raw presence update event model. :type payload: :class:`RawPresenceUpdateEvent` - + Messages ~~~~~~~~~ @@ -2456,6 +2456,8 @@ of :class:`enum.Enum`. Possible attributes for :class:`AuditLogDiff`: - :attr:`~AuditLogDiff.colour` + - :attr:`~AuditLogDiff.secondary_colour` + - :attr:`~AuditLogDiff.tertiary_colour` - :attr:`~AuditLogDiff.mentionable` - :attr:`~AuditLogDiff.hoist` - :attr:`~AuditLogDiff.icon` @@ -2479,6 +2481,8 @@ of :class:`enum.Enum`. Possible attributes for :class:`AuditLogDiff`: - :attr:`~AuditLogDiff.colour` + - :attr:`~AuditLogDiff.secondary_colour` + - :attr:`~AuditLogDiff.tertiary_colour` - :attr:`~AuditLogDiff.mentionable` - :attr:`~AuditLogDiff.hoist` - :attr:`~AuditLogDiff.icon` @@ -2496,6 +2500,8 @@ of :class:`enum.Enum`. Possible attributes for :class:`AuditLogDiff`: - :attr:`~AuditLogDiff.colour` + - :attr:`~AuditLogDiff.secondary_colour` + - :attr:`~AuditLogDiff.tertiary_colour` - :attr:`~AuditLogDiff.mentionable` - :attr:`~AuditLogDiff.hoist` - :attr:`~AuditLogDiff.name` @@ -4210,6 +4216,24 @@ AuditLogDiff :type: :class:`Colour` + .. attribute:: secondary_colour + secondary_color + + The secondary colour of a role. + + See also :attr:`Role.secondary_colour` + + :type: Optional[:class:`Colour`] + + .. attribute:: tertiary_colour + tertiary_color + + The tertiary colour of a role. + + See also :attr:`Role.tertiary_colour` + + :type: Optional[:class:`Colour`] + .. attribute:: hoist Whether the role is being hoisted or not. From 13dc9c9d1600691ea74e253cc739c563c117410e Mon Sep 17 00:00:00 2001 From: Ken-Miles <46692523+Ken-Miles@users.noreply.github.com> Date: Tue, 15 Jul 2025 18:46:32 -0500 Subject: [PATCH 034/138] Fix miswording in docstring about bot.emojis --- discord/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/client.py b/discord/client.py index 68422435bfa8..4f16e6ff5bbf 100644 --- a/discord/client.py +++ b/discord/client.py @@ -381,7 +381,7 @@ def emojis(self) -> Sequence[Emoji]: .. note:: - This not include the emojis that are owned by the application. + This does not include the emojis that are owned by the application. Use :meth:`.fetch_application_emoji` to get those. """ return self._connection.emojis From 7724764ffebebf1584847b49ff6823f6c0402d01 Mon Sep 17 00:00:00 2001 From: blord0 <68508813+blord0@users.noreply.github.com> Date: Fri, 18 Jul 2025 01:41:30 +0100 Subject: [PATCH 035/138] Add ability to use primary guild (clan) data for users Co-authored-by: blord0 Co-authored-by: DA344 <108473820+DA-344@users.noreply.github.com> Co-authored-by: dolfies --- discord/__init__.py | 1 + discord/asset.py | 9 ++++ discord/member.py | 16 ++++++- discord/primary_guild.py | 90 ++++++++++++++++++++++++++++++++++++++++ discord/types/user.py | 7 ++++ discord/user.py | 21 +++++++++- docs/api.rst | 8 ++++ 7 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 discord/primary_guild.py diff --git a/discord/__init__.py b/discord/__init__.py index 48fe1092541e..3e6e1c0e6d8b 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -73,6 +73,7 @@ from .soundboard import * from .subscription import * from .presences import * +from .primary_guild import * class VersionInfo(NamedTuple): diff --git a/discord/asset.py b/discord/asset.py index e3422f3110d4..cbf7dd4b2976 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -346,6 +346,15 @@ def _from_user_banner(cls, state: _State, user_id: int, banner_hash: str) -> Sel animated=animated, ) + @classmethod + def _from_primary_guild(cls, state: _State, guild_id: int, icon_hash: str) -> Self: + return cls( + state, + url=f'{cls.BASE}/guild-tag-badges/{guild_id}/{icon_hash}.png?size=64', + key=icon_hash, + animated=False, + ) + def __str__(self) -> str: return self._url diff --git a/discord/member.py b/discord/member.py index ed52600ddbfa..9f6b9daf24bf 100644 --- a/discord/member.py +++ b/discord/member.py @@ -74,6 +74,7 @@ GuildVoiceState as GuildVoiceStatePayload, VoiceState as VoiceStatePayload, ) + from .primary_guild import PrimaryGuild VocalGuildChannel = Union[VoiceChannel, StageChannel] @@ -309,6 +310,7 @@ class Member(discord.abc.Messageable, _UserTag): accent_colour: Optional[Colour] avatar_decoration: Optional[Asset] avatar_decoration_sku_id: Optional[int] + primary_guild: PrimaryGuild def __init__(self, *, data: MemberWithUserPayload, guild: Guild, state: ConnectionState): self._state: ConnectionState = state @@ -452,9 +454,11 @@ def _update_inner_user(self, user: UserPayload) -> Optional[Tuple[User, User]]: u.global_name, u._public_flags, u._avatar_decoration_data['sku_id'] if u._avatar_decoration_data is not None else None, + u._primary_guild, ) decoration_payload = user.get('avatar_decoration_data') + primary_guild_payload = user.get('primary_guild', None) # These keys seem to always be available modified = ( user['username'], @@ -463,16 +467,26 @@ def _update_inner_user(self, user: UserPayload) -> Optional[Tuple[User, User]]: user.get('global_name'), user.get('public_flags', 0), decoration_payload['sku_id'] if decoration_payload is not None else None, + primary_guild_payload, ) if original != modified: to_return = User._copy(self._user) - u.name, u.discriminator, u._avatar, u.global_name, u._public_flags, u._avatar_decoration_data = ( + ( + u.name, + u.discriminator, + u._avatar, + u.global_name, + u._public_flags, + u._avatar_decoration_data, + u._primary_guild, + ) = ( user['username'], user['discriminator'], user['avatar'], user.get('global_name'), user.get('public_flags', 0), decoration_payload, + primary_guild_payload, ) # Signal to dispatch on_user_update return to_return, u diff --git a/discord/primary_guild.py b/discord/primary_guild.py new file mode 100644 index 000000000000..b65275a1f3b3 --- /dev/null +++ b/discord/primary_guild.py @@ -0,0 +1,90 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +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. +""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING +from datetime import datetime + +from .asset import Asset +from .utils import snowflake_time, _get_as_snowflake + +if TYPE_CHECKING: + from .state import ConnectionState + from .types.user import PrimaryGuild as PrimaryGuildPayload + from typing_extensions import Self + + +class PrimaryGuild: + """Represents the primary guild identity of a :class:`User` + + .. versionadded:: 2.6 + + Attributes + ----------- + id: Optional[:class:`int`] + The ID of the user's primary guild, if any. + tag: Optional[:class:`str`] + The primary guild's tag. + identity_enabled: Optional[:class:`bool`] + Whether the user has their primary guild publicly displayed. If ``None``, the user has a public guild but has not reaffirmed the guild identity after a change + + .. warning:: + + Users can have their primary guild publicly displayed while still having an :attr:`id` of ``None``. Be careful when checking this attribute! + """ + + __slots__ = ('id', 'identity_enabled', 'tag', '_badge', '_state') + + def __init__(self, *, state: ConnectionState, data: PrimaryGuildPayload) -> None: + self._state = state + self._update(data) + + def _update(self, data: PrimaryGuildPayload): + self.id = _get_as_snowflake(data, 'identity_guild_id') + self.identity_enabled = data['identity_enabled'] + self.tag = data.get('tag', None) + self._badge = data.get('badge') + + @property + def badge(self) -> Optional[Asset]: + """Optional[:class:`Asset`]: Returns the primary guild's asset""" + if self._badge is not None and self.id is not None: + return Asset._from_primary_guild(self._state, self.id, self._badge) + return None + + @property + def created_at(self) -> Optional[datetime]: + """Optional[:class:`datetime.datetime`]: Returns the primary guild's creation time in UTC.""" + if self.id is not None: + return snowflake_time(self.id) + return None + + @classmethod + def _default(cls, state: ConnectionState) -> Self: + payload: PrimaryGuildPayload = {"identity_enabled": False} # type: ignore + return cls(state=state, data=payload) + + def __repr__(self) -> str: + return f'' diff --git a/discord/types/user.py b/discord/types/user.py index 1f027ce9d9ac..b2b213ecf6d0 100644 --- a/discord/types/user.py +++ b/discord/types/user.py @@ -54,3 +54,10 @@ class User(PartialUser, total=False): flags: int premium_type: PremiumType public_flags: int + + +class PrimaryGuild(TypedDict): + identity_guild_id: Optional[int] + identity_enabled: Optional[bool] + tag: Optional[str] + badge: Optional[str] diff --git a/discord/user.py b/discord/user.py index c5391372aa58..636c909f3b40 100644 --- a/discord/user.py +++ b/discord/user.py @@ -32,6 +32,7 @@ from .enums import DefaultAvatar from .flags import PublicUserFlags from .utils import snowflake_time, _bytes_to_base64_data, MISSING, _get_as_snowflake +from .primary_guild import PrimaryGuild if TYPE_CHECKING: from typing_extensions import Self @@ -43,7 +44,12 @@ from .message import Message from .state import ConnectionState from .types.channel import DMChannel as DMChannelPayload - from .types.user import PartialUser as PartialUserPayload, User as UserPayload, AvatarDecorationData + from .types.user import ( + PartialUser as PartialUserPayload, + User as UserPayload, + AvatarDecorationData, + PrimaryGuild as PrimaryGuildPayload, + ) __all__ = ( @@ -71,6 +77,7 @@ class BaseUser(_UserTag): '_public_flags', '_state', '_avatar_decoration_data', + '_primary_guild', ) if TYPE_CHECKING: @@ -86,6 +93,7 @@ class BaseUser(_UserTag): _accent_colour: Optional[int] _public_flags: int _avatar_decoration_data: Optional[AvatarDecorationData] + _primary_guild: Optional[PrimaryGuildPayload] def __init__(self, *, state: ConnectionState, data: Union[UserPayload, PartialUserPayload]) -> None: self._state = state @@ -123,6 +131,7 @@ def _update(self, data: Union[UserPayload, PartialUserPayload]) -> None: self.bot = data.get('bot', False) self.system = data.get('system', False) self._avatar_decoration_data = data.get('avatar_decoration_data') + self._primary_guild = data.get('primary_guild', None) @classmethod def _copy(cls, user: Self) -> Self: @@ -139,6 +148,7 @@ def _copy(cls, user: Self) -> Self: self._state = user._state self._public_flags = user._public_flags self._avatar_decoration_data = user._avatar_decoration_data + self._primary_guild = user._primary_guild return self @@ -305,6 +315,15 @@ def display_name(self) -> str: return self.global_name return self.name + @property + def primary_guild(self) -> PrimaryGuild: + """:class:`PrimaryGuild`: Returns the user's primary guild. + + .. versionadded:: 2.6""" + if self._primary_guild is not None: + return PrimaryGuild(state=self._state, data=self._primary_guild) + return PrimaryGuild._default(self._state) + def mentioned_in(self, message: Message) -> bool: """Checks if the user is mentioned in the specified message. diff --git a/docs/api.rst b/docs/api.rst index c7d9e351f503..781a47b24a36 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -5481,6 +5481,14 @@ ClientStatus .. autoclass:: ClientStatus() :members: +PrimaryGuild +~~~~~~~~~~~~ + +.. attributetable:: PrimaryGuild + +.. autoclass:: PrimaryGuild() + :members: + Data Classes -------------- From 0e97ef21aa808f7f1f80e8dec5eb65c583d26a79 Mon Sep 17 00:00:00 2001 From: z03h <7235242+z03h@users.noreply.github.com> Date: Tue, 22 Jul 2025 14:45:02 -0400 Subject: [PATCH 036/138] Fix key error on role create or delete --- discord/audit_logs.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/discord/audit_logs.py b/discord/audit_logs.py index b781dcf80c08..40166f5489cb 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -409,8 +409,8 @@ def __init__(self, entry: AuditLogEntry, data: List[AuditLogChangePayload]): # special case for colors to set secondary and tertiary colos/colour attributes if attr == 'colors': - self._handle_colours(self.before, elem['old_value']) # type: ignore # should be a RoleColours dict - self._handle_colours(self.after, elem['new_value']) # type: ignore # should be a RoleColours dict + self._handle_colours(self.before, elem.get('old_value')) # type: ignore # should be a RoleColours dict + self._handle_colours(self.after, elem.get('new_value')) # type: ignore # should be a RoleColours dict continue try: @@ -545,13 +545,18 @@ def _handle_trigger_attr_update( except (AttributeError, TypeError): pass - def _handle_colours(self, diff: AuditLogDiff, colours: RoleColours): - # handle colours to multiple colour attributes - diff.color = diff.colour = Colour(colours['primary_color']) - - secondary_colour = colours['secondary_color'] - tertiary_colour = colours['tertiary_color'] + def _handle_colours(self, diff: AuditLogDiff, colours: Optional[RoleColours]): + if colours is not None: + # handle colours to multiple colour attributes + colour = Colour(colours['primary_color']) + secondary_colour = colours['secondary_color'] + tertiary_colour = colours['tertiary_color'] + else: + colour = None + secondary_colour = None + tertiary_colour = None + diff.color = diff.colour = colour diff.secondary_color = diff.secondary_colour = Colour(secondary_colour) if secondary_colour is not None else None diff.tertiary_color = diff.tertiary_colour = Colour(tertiary_colour) if tertiary_colour is not None else None From 5f4a822c2ee357743e76f2f67253871c384842df Mon Sep 17 00:00:00 2001 From: Sacul Date: Mon, 28 Jul 2025 01:06:12 +0800 Subject: [PATCH 037/138] Add missing fill_with_flags decorator to InviteFlags class --- discord/flags.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/flags.py b/discord/flags.py index 59a4909b8557..99f10d2f0570 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -2400,6 +2400,7 @@ def content_inventory_entry(self): return 1 << 5 +@fill_with_flags() class InviteFlags(BaseFlags): r"""Wraps up the Discord Invite flags From e715ad6419bebe2bd47a31843616b528fcbf7517 Mon Sep 17 00:00:00 2001 From: Sacul Date: Mon, 28 Jul 2025 01:07:19 +0800 Subject: [PATCH 038/138] Add support for automod_quarantined_guild_tag member flag --- discord/audit_logs.py | 13 +++++++++++++ discord/enums.py | 4 +++- discord/flags.py | 9 +++++++++ discord/types/audit_log.py | 1 + docs/api.rst | 17 +++++++++++++++++ 5 files changed, 43 insertions(+), 1 deletion(-) diff --git a/discord/audit_logs.py b/discord/audit_logs.py index 40166f5489cb..2c0fd610fb87 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -614,6 +614,11 @@ class _AuditLogProxyAutoModAction(_AuditLogProxy): channel: Optional[Union[abc.GuildChannel, Thread]] +class _AuditLogProxyAutoModActionQuarantineUser(_AuditLogProxy): + automod_rule_name: str + automod_rule_trigger_type: str + + class _AuditLogProxyMemberKickOrMemberRoleUpdate(_AuditLogProxy): integration_type: Optional[str] @@ -704,6 +709,7 @@ def _from_data(self, data: AuditLogEntryPayload) -> None: _AuditLogProxyStageInstanceAction, _AuditLogProxyMessageBulkDelete, _AuditLogProxyAutoModAction, + _AuditLogProxyAutoModActionQuarantineUser, _AuditLogProxyMemberKickOrMemberRoleUpdate, Member, User, None, PartialIntegration, Role, Object @@ -759,6 +765,13 @@ def _from_data(self, data: AuditLogEntryPayload) -> None: ), channel=channel, ) + elif self.action is enums.AuditLogAction.automod_quarantine_user: + self.extra = _AuditLogProxyAutoModActionQuarantineUser( + automod_rule_name=extra['auto_moderation_rule_name'], + automod_rule_trigger_type=enums.try_enum( + enums.AutoModRuleTriggerType, extra['auto_moderation_rule_trigger_type'] + ), + ) elif self.action.name.startswith('overwrite_'): # the overwrite_ actions have a dict with some information diff --git a/discord/enums.py b/discord/enums.py index acc78012014f..5ee07044c732 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -398,6 +398,7 @@ class AuditLogAction(Enum): automod_block_message = 143 automod_flag_message = 144 automod_timeout_member = 145 + automod_quarantine_user = 146 creator_monetization_request_created = 150 creator_monetization_terms_accepted = 151 # fmt: on @@ -460,6 +461,7 @@ def category(self) -> Optional[AuditLogActionCategory]: AuditLogAction.automod_block_message: None, AuditLogAction.automod_flag_message: None, AuditLogAction.automod_timeout_member: None, + AuditLogAction.automod_quarantine_user: None, AuditLogAction.creator_monetization_request_created: None, AuditLogAction.creator_monetization_terms_accepted: None, AuditLogAction.soundboard_sound_create: AuditLogActionCategory.create, @@ -506,7 +508,7 @@ def target_type(self) -> Optional[str]: return 'integration_or_app_command' elif 139 < v < 143: return 'auto_moderation' - elif v < 146: + elif v < 147: return 'user' elif v < 152: return 'creator_monetization' diff --git a/discord/flags.py b/discord/flags.py index 99f10d2f0570..8a7a66954aae 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -2094,6 +2094,15 @@ def automod_quarantined_username(self): """ return 1 << 7 + @flag_value + def automod_quarantined_guild_tag(self): + """:class:`bool`: Returns ``True`` if the member's guild tag has been + blocked by AutoMod. + + .. versionadded:: 2.6 + """ + return 1 << 10 + @flag_value def dm_settings_upsell_acknowledged(self): """:class:`bool`: Returns ``True`` if the member has dismissed the DM settings upsell. diff --git a/discord/types/audit_log.py b/discord/types/audit_log.py index c9d3056959af..cc4ad8363a27 100644 --- a/discord/types/audit_log.py +++ b/discord/types/audit_log.py @@ -97,6 +97,7 @@ 143, 144, 145, + 146, 150, 151, ] diff --git a/docs/api.rst b/docs/api.rst index 781a47b24a36..00878f393347 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3055,6 +3055,23 @@ of :class:`enum.Enum`. .. versionadded:: 2.1 + .. attribute:: automod_quarantine_user + + An automod rule quarantined a member. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + a :class:`Member` with the ID of the person who triggered the automod rule. + + When this is the action, the type of :attr:`~AuditLogEntry.extra` is + set to an unspecified proxy object with 2 attributes: + + - ``automod_rule_name``: The name of the automod rule that was triggered. + - ``automod_rule_trigger_type``: A :class:`AutoModRuleTriggerType` representation of the rule type that was triggered. + + When this is the action, :attr:`AuditLogEntry.changes` is empty. + + .. versionadded:: 2.6 + .. attribute:: creator_monetization_request_created A request to monetize the server was created. From 5b81a99b60ce89e4e9f412aa957dc74dd5c91a4b Mon Sep 17 00:00:00 2001 From: Mohammed Aman Jukaku <59416451+Amjuks@users.noreply.github.com> Date: Sun, 27 Jul 2025 23:13:21 +0530 Subject: [PATCH 039/138] Fix error when sending non-interactive views via partial webhooks --- discord/webhook/async_.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 3b62b10faa2c..dbb3f1ed9e96 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -1872,7 +1872,7 @@ async def send( if wait: msg = self._create_message(data, thread=thread) - if view is not MISSING and not view.is_finished(): + if view is not MISSING and not view.is_finished() and view.is_dispatchable(): message_id = None if msg is None else msg.id self._state.store_view(view, message_id) From 22ade98db7f446bc65e37e78e0039a5d4fae9e2e Mon Sep 17 00:00:00 2001 From: Ankita Tudubucket <106295287+tudubucket@users.noreply.github.com> Date: Mon, 28 Jul 2025 02:09:35 +0700 Subject: [PATCH 040/138] Skip GUILD_MEMBER_ADD if member already cached --- discord/state.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/discord/state.py b/discord/state.py index 0fbeadea2057..223a099238a5 100644 --- a/discord/state.py +++ b/discord/state.py @@ -1101,6 +1101,12 @@ def parse_guild_member_add(self, data: gw.GuildMemberAddEvent) -> None: _log.debug('GUILD_MEMBER_ADD referencing an unknown guild ID: %s. Discarding.', data['guild_id']) return + member_id = int(data['user']['id']) + member = guild.get_member(member_id) + if member is not None: + _log.debug('GUILD_MEMBER_ADD referencing an already cached member ID: %s. Discarding.', member_id) + return + member = Member(guild=guild, data=data, state=self) if self.member_cache_flags.joined: guild._add_member(member) From ce9f5ad1ba07d070a50bf41b9d26f68a85be610b Mon Sep 17 00:00:00 2001 From: shea <80988430+px1w@users.noreply.github.com> Date: Mon, 28 Apr 2025 12:41:49 +0000 Subject: [PATCH 041/138] Fix context install decorators to correctly restrict commands --- discord/app_commands/commands.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/discord/app_commands/commands.py b/discord/app_commands/commands.py index 1496a82bba51..dc895b483324 100644 --- a/discord/app_commands/commands.py +++ b/discord/app_commands/commands.py @@ -2524,7 +2524,10 @@ def inner(f: T) -> T: allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext() f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment - allowed_contexts.guild = True + # Ensure that only Guild context is allowed + allowed_contexts.guild = False # Enable guild context + allowed_contexts.private_channel = False # Disable private channel context + allowed_contexts.dm_channel = False # Disable DM context return f @@ -2578,7 +2581,10 @@ def inner(f: T) -> T: allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext() f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment - allowed_contexts.private_channel = True + # Ensure that only Private Channel context is allowed + allowed_contexts.guild = False # Disable guild context + allowed_contexts.private_channel = True # Enable private channel context + allowed_contexts.dm_channel = False # Disable DM context return f @@ -2630,7 +2636,11 @@ def inner(f: T) -> T: allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext() f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment - allowed_contexts.dm_channel = True + # Ensure that only DM context is allowed + allowed_contexts.guild = False # Disable guild context + allowed_contexts.private_channel = False # Disable private channel context + allowed_contexts.dm_channel = True # Enable DM context + return f # Check if called with parentheses or not @@ -2724,6 +2734,7 @@ def inner(f: T) -> T: f.__discord_app_commands_installation_types__ = allowed_installs # type: ignore # Runtime attribute assignment allowed_installs.guild = True + allowed_installs.user = False return f @@ -2774,6 +2785,7 @@ def inner(f: T) -> T: f.__discord_app_commands_installation_types__ = allowed_installs # type: ignore # Runtime attribute assignment allowed_installs.user = True + allowed_installs.guild = False return f From 2695fa056a05113ce01b12951e05ec4b735edb10 Mon Sep 17 00:00:00 2001 From: owocado <24418520+owocado@users.noreply.github.com> Date: Wed, 30 Jul 2025 15:30:10 +0530 Subject: [PATCH 042/138] Fix guild_only app command decorator --- discord/app_commands/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/app_commands/commands.py b/discord/app_commands/commands.py index dc895b483324..2568f0027e04 100644 --- a/discord/app_commands/commands.py +++ b/discord/app_commands/commands.py @@ -2525,7 +2525,7 @@ def inner(f: T) -> T: f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment # Ensure that only Guild context is allowed - allowed_contexts.guild = False # Enable guild context + allowed_contexts.guild = True # Enable guild context allowed_contexts.private_channel = False # Disable private channel context allowed_contexts.dm_channel = False # Disable DM context From 348c7d787348371f51b06646d936d0bbeb598136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20N=C3=B8rgaard?= Date: Wed, 30 Jul 2025 11:01:28 +0100 Subject: [PATCH 043/138] Fix audit log automod_rule_trigger_type extra missing --- discord/audit_logs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/audit_logs.py b/discord/audit_logs.py index 2c0fd610fb87..449e874dee3e 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -761,7 +761,7 @@ def _from_data(self, data: AuditLogEntryPayload) -> None: self.extra = _AuditLogProxyAutoModAction( automod_rule_name=extra['auto_moderation_rule_name'], automod_rule_trigger_type=enums.try_enum( - enums.AutoModRuleTriggerType, extra['auto_moderation_rule_trigger_type'] + enums.AutoModRuleTriggerType, int(extra['auto_moderation_rule_trigger_type']) ), channel=channel, ) @@ -769,7 +769,7 @@ def _from_data(self, data: AuditLogEntryPayload) -> None: self.extra = _AuditLogProxyAutoModActionQuarantineUser( automod_rule_name=extra['auto_moderation_rule_name'], automod_rule_trigger_type=enums.try_enum( - enums.AutoModRuleTriggerType, extra['auto_moderation_rule_trigger_type'] + enums.AutoModRuleTriggerType, int(extra['auto_moderation_rule_trigger_type']) ), ) From 6e7fc133d1e3d8d34e861c16e99077885cacf576 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Wed, 30 Jul 2025 22:03:15 +0100 Subject: [PATCH 044/138] Add support for new RPC Activity fields --- discord/activity.py | 34 +++++++++++++++++++++++++++++++++- discord/enums.py | 7 +++++++ discord/types/activity.py | 6 ++++++ docs/api.rst | 19 +++++++++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/discord/activity.py b/discord/activity.py index 0fc0faa64652..3abaa0c3d56a 100644 --- a/discord/activity.py +++ b/discord/activity.py @@ -28,7 +28,7 @@ from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union, overload from .asset import Asset -from .enums import ActivityType, try_enum +from .enums import ActivityType, StatusDisplayType, try_enum from .colour import Colour from .partial_emoji import PartialEmoji from .utils import _get_as_snowflake @@ -180,8 +180,10 @@ class Activity(BaseActivity): - ``large_image``: A string representing the ID for the large image asset. - ``large_text``: A string representing the text when hovering over the large image asset. + - ``large_url``: A string representing the URL of the large image asset. - ``small_image``: A string representing the ID for the small image asset. - ``small_text``: A string representing the text when hovering over the small image asset. + - ``small_url``: A string representing the URL of the small image asset. party: :class:`dict` A dictionary representing the activity party. It contains the following optional keys: @@ -195,6 +197,19 @@ class Activity(BaseActivity): emoji: Optional[:class:`PartialEmoji`] The emoji that belongs to this activity. + details_url: Optional[:class:`str`] + A URL that is linked to when clicking on the details text of the activity. + + .. versionadded:: 2.6 + state_url: Optional[:class:`str`] + A URL that is linked to when clicking on the state text of the activity. + + .. versionadded:: 2.6 + status_display_type: Optional[:class:`StatusDisplayType`] + Determines which field from the user's status text is displayed + in the members list. + + .. versionadded:: 2.6 """ __slots__ = ( @@ -213,6 +228,9 @@ class Activity(BaseActivity): 'application_id', 'emoji', 'buttons', + 'state_url', + 'details_url', + 'status_display_type', ) def __init__(self, **kwargs: Any) -> None: @@ -239,6 +257,18 @@ def __init__(self, **kwargs: Any) -> None: emoji = kwargs.pop('emoji', None) self.emoji: Optional[PartialEmoji] = PartialEmoji.from_dict(emoji) if emoji is not None else None + self.state_url: Optional[str] = kwargs.pop('state_url') + self.details_url: Optional[str] = kwargs.pop('details_url') + + status_display_type = kwargs.pop('status_display_type', None) + self.status_display_type: Optional[StatusDisplayType] = ( + status_display_type + if isinstance(status_display_type, StatusDisplayType) + else try_enum(StatusDisplayType, status_display_type) + if status_display_type is not None + else None + ) + def __repr__(self) -> str: attrs = ( ('type', self.type), @@ -267,6 +297,8 @@ def to_dict(self) -> Dict[str, Any]: ret['type'] = int(self.type) if self.emoji: ret['emoji'] = self.emoji.to_dict() + if self.status_display_type: + ret['status_display_type'] = int(self.status_display_type.value) return ret @property diff --git a/discord/enums.py b/discord/enums.py index 5ee07044c732..1c978c7124ab 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -78,6 +78,7 @@ 'VoiceChannelEffectAnimationType', 'SubscriptionStatus', 'MessageReferenceType', + 'StatusDisplayType', ) @@ -914,6 +915,12 @@ class SubscriptionStatus(Enum): inactive = 2 +class StatusDisplayType(Enum): + name = 0 # pyright: ignore[reportAssignmentType] + state = 1 + details = 2 + + def create_unknown_value(cls: Type[E], val: Any) -> E: value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below name = f'unknown_{val}' diff --git a/discord/types/activity.py b/discord/types/activity.py index f57334936338..07f25d3bfa76 100644 --- a/discord/types/activity.py +++ b/discord/types/activity.py @@ -31,6 +31,7 @@ StatusType = Literal['idle', 'dnd', 'online', 'offline'] +StatusDisplayType = Literal[0, 1, 2] class PartialPresenceUpdate(TypedDict): @@ -62,6 +63,8 @@ class ActivityAssets(TypedDict, total=False): large_text: str small_image: str small_text: str + large_url: str + small_url: str class ActivitySecrets(TypedDict, total=False): @@ -104,3 +107,6 @@ class Activity(_BaseActivity, total=False): instance: bool buttons: List[str] sync_id: str + state_url: str + details_url: str + status_display_type: Optional[StatusDisplayType] diff --git a/docs/api.rst b/docs/api.rst index 00878f393347..d7014d374221 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3898,6 +3898,25 @@ of :class:`enum.Enum`. An alias for :attr:`.default`. +.. class:: StatusDisplayType + + Represents which field is of the user's activity is + displayed in the members list. + + .. versionadded:: 2.6 + + .. attribute:: name + + The name of the activity is displayed. + + .. attribute:: state + + The state of the activity is displayed. + + .. attribute:: details + + The details of the activity are displayed. + .. _discord-api-audit-logs: Audit Log Data From 1cd90f8b1cb48f784ddb7413f6eeb91cd0eedc7e Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Wed, 30 Jul 2025 22:08:08 +0100 Subject: [PATCH 045/138] [commands] Fix converters not working with Optional for hybrids --- discord/ext/commands/hybrid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ext/commands/hybrid.py b/discord/ext/commands/hybrid.py index 99b537ca1495..c584aca8fe21 100644 --- a/discord/ext/commands/hybrid.py +++ b/discord/ext/commands/hybrid.py @@ -256,7 +256,7 @@ def replace_parameter( # Special case Optional[X] where X is a single type that can optionally be a converter inner = args[0] is_inner_transformer = is_transformer(inner) - if is_converter(inner) and not is_inner_transformer: + if (is_converter(inner) or inner in CONVERTER_MAPPING) and not is_inner_transformer: param = param.replace(annotation=Optional[ConverterTransformer(inner, original)]) else: raise From cb7f17020d03cc77eac2e65367a83326f77f1b70 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Wed, 30 Jul 2025 22:09:56 +0100 Subject: [PATCH 046/138] Deprecate Guild.delete and various parameters for Guild.edit --- discord/guild.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/discord/guild.py b/discord/guild.py index b03dbbea6445..9506780a2752 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -1986,12 +1986,16 @@ async def leave(self) -> None: """ await self._state.http.leave_guild(self.id) + @utils.deprecated() async def delete(self) -> None: """|coro| Deletes the guild. You must be the guild owner to delete the guild. + .. deprecated:: 2.6 + This method is deprecated and will be removed in a future version. + Raises -------- HTTPException @@ -2091,6 +2095,9 @@ async def edit( owner: :class:`Member` The new owner of the guild to transfer ownership to. Note that you must be owner of the guild to do this. + + .. deprecated:: 2.6 + This parameter is deprecated and will be removed in a future version as bots can no longer own guilds. verification_level: :class:`VerificationLevel` The new verification level for the guild. default_notifications: :class:`NotificationLevel` @@ -2099,6 +2106,9 @@ async def edit( The new explicit content filter for the guild. vanity_code: :class:`str` The new vanity code for the guild. + + .. deprecated:: 2.6 + This parameter is deprecated and will be removed in a future version as bots can no longer set this. system_channel: Optional[:class:`TextChannel`] The new channel that is used for the system channel. Could be ``None`` for no system channel. system_channel_flags: :class:`SystemChannelFlags` @@ -2146,6 +2156,8 @@ async def edit( Note that you must be owner of the guild to do this. .. versionadded:: 2.3 + .. deprecated:: 2.6 + This parameter is deprecated and will be removed in a future version as bots can no longer own guilds. reason: Optional[:class:`str`] The reason for editing this guild. Shows up on the audit log. From 4496df79d047b7c26f0413c8a34086d309b96904 Mon Sep 17 00:00:00 2001 From: owocado <24418520+owocado@users.noreply.github.com> Date: Thu, 31 Jul 2025 03:33:36 +0530 Subject: [PATCH 047/138] Fix crash in Activity.__init__ with missing keys not all activity payloads have `state_url` and `details_url` keys present. --- discord/activity.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/activity.py b/discord/activity.py index 3abaa0c3d56a..053d06dc7306 100644 --- a/discord/activity.py +++ b/discord/activity.py @@ -257,8 +257,8 @@ def __init__(self, **kwargs: Any) -> None: emoji = kwargs.pop('emoji', None) self.emoji: Optional[PartialEmoji] = PartialEmoji.from_dict(emoji) if emoji is not None else None - self.state_url: Optional[str] = kwargs.pop('state_url') - self.details_url: Optional[str] = kwargs.pop('details_url') + self.state_url: Optional[str] = kwargs.pop('state_url', None) + self.details_url: Optional[str] = kwargs.pop('details_url', None) status_display_type = kwargs.pop('status_display_type', None) self.status_display_type: Optional[StatusDisplayType] = ( From 21fed315c7bf180081b07bafaf2a22532eeafed7 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Fri, 8 Aug 2025 09:09:48 +0200 Subject: [PATCH 048/138] Add FAQ about guild specific app commands --- docs/faq.rst | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/docs/faq.rst b/docs/faq.rst index 16d03362abef..76e2e1b8a64b 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -500,3 +500,82 @@ My bot's commands are not showing up! ``https://discord.com/oauth2/authorize?client_id=&scope=applications.commands+bot``. Alternatively, if you use :func:`utils.oauth_url`, you can call the function as such: ``oauth_url(, scopes=("bot", "applications.commands"))``. + +How do I restrict a command to a specific guild? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To restrict an application command to one or more guilds, you must register it as a **guild command** instead of a +global command. Guild commands are only available in the specified guild(s). + +The most straightforward way is to use the :meth:`~app_commands.guilds` decorator on your command or GroupCog. + +``123456789012345678`` should be replaced with the actual guild ID you want to restrict the command to. + +.. code-block:: python3 + + @app_commands.command() # or @tree.command() + @app_commands.guilds(123456789012345678) # or @app_commands.guilds(discord.Object(123456789012345678)) + async def ping(interaction: Interaction): + await interaction.response.send_message("Pong!") + + # or GroupCog (applies to all subcommands): + + @app_commands.guilds(123456789012345678) + class MyGroup(commands.GroupCog): + @app_commands.command() + async def pong(self, interaction: Interaction): + await interaction.response.send_message("Ping!") + +After that, you must :meth:`~app_commands.CommandTree.sync` the command tree for each guild: + +.. code-block:: python3 + + await tree.sync(guild=discord.Object(123456789012345678)) + +Other methods to restrict commands to specific guilds include: + +- Using the ``guild`` or ``guilds`` argument in the :meth:`~app_commands.CommandTree.command` decorator: + + .. code-block:: python3 + + @tree.command(guild=discord.Object(123456789012345678)) + async def ping(interaction: Interaction): + await interaction.response.send_message("Pong!") + +- Adding commands with :meth:`~app_commands.CommandTree.add_command` and specifying ``guild`` or ``guilds``: + + .. code-block:: python3 + + @app_commands.command() + async def ping(interaction: Interaction): + await interaction.response.send_message("Pong!") + + tree.add_command(ping, guild=discord.Object(123456789012345678)) + + .. warning:: + + Do not combine this method with the :meth:`~app_commands.CommandTree.command` decorator, + as it will cause duplicate commands. + +- Using ``guild`` or ``guilds`` in :meth:`~ext.commands.Bot.add_cog`: + + This is mainly for :class:`~ext.commands.GroupCog`, but also works for cogs with application commands. + Note: This does not work with hybrid app commands (:issue:`9366`). + + .. code-block:: python3 + + class MyCog(commands.Cog): + @app_commands.command() + async def ping(self, interaction: Interaction): + await interaction.response.send_message("Pong!") + + async def setup(bot: commands.Bot) -> None: + await bot.add_cog(MyCog(...), guild=discord.Object(123456789012345678)) + +- Using :meth:`~app_commands.CommandTree.copy_global_to`: + + This copies all global commands to a specific guild. This is mainly for development purposes. + + .. code-block:: python3 + + tree.copy_global_to(guild=discord.Object(123456789012345678)) From 7b3f7980447e64bff6f3ca12ca975c4d743d62a9 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Fri, 8 Aug 2025 09:10:32 +0200 Subject: [PATCH 049/138] Add support for guild onboarding Co-authored-by: Josh <8677174+bijij@users.noreply.github.com> Co-authored-by: Josh Co-authored-by: numbermaniac <5206120+numbermaniac@users.noreply.github.com> Co-authored-by: Andrin <65789180+Puncher1@users.noreply.github.com> Co-authored-by: Andrin Schaller <65789180+codeofandrin@users.noreply.github.com> Co-authored-by: DA344 <108473820+DA-344@users.noreply.github.com> --- discord/__init__.py | 1 + discord/audit_logs.py | 25 ++- discord/enums.py | 32 ++++ discord/guild.py | 74 ++++++++ discord/http.py | 37 ++++ discord/onboarding.py | 369 ++++++++++++++++++++++++++++++++++++ discord/partial_emoji.py | 6 + discord/reaction.py | 2 +- discord/state.py | 2 +- discord/types/audit_log.py | 33 +++- discord/types/onboarding.py | 72 +++++++ discord/webhook/async_.py | 2 +- docs/api.rst | 227 +++++++++++++++++++++- 13 files changed, 869 insertions(+), 13 deletions(-) create mode 100644 discord/onboarding.py create mode 100644 discord/types/onboarding.py diff --git a/discord/__init__.py b/discord/__init__.py index 3e6e1c0e6d8b..208519a91990 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -74,6 +74,7 @@ from .subscription import * from .presences import * from .primary_guild import * +from .onboarding import * class VersionInfo(NamedTuple): diff --git a/discord/audit_logs.py b/discord/audit_logs.py index 449e874dee3e..5e434cbc0c1e 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -44,6 +44,7 @@ from .threads import Thread from .integrations import PartialIntegration from .channel import ForumChannel, StageChannel, ForumTag +from .onboarding import OnboardingPrompt, OnboardingPromptOption __all__ = ( 'AuditLogDiff', @@ -73,6 +74,7 @@ from .types.snowflake import Snowflake from .types.command import ApplicationCommandPermissions from .types.automod import AutoModerationAction + from .types.onboarding import Prompt as PromptPayload, PromptOption as PromptOptionPayload from .user import User from .app_commands import AppCommand from .webhook import Webhook @@ -246,6 +248,16 @@ def _transform_default_emoji(entry: AuditLogEntry, data: str) -> PartialEmoji: return PartialEmoji(name=data) +def _transform_onboarding_prompts(entry: AuditLogEntry, data: List[PromptPayload]) -> List[OnboardingPrompt]: + return [OnboardingPrompt.from_dict(data=prompt, state=entry._state, guild=entry.guild) for prompt in data] + + +def _transform_onboarding_prompt_options( + entry: AuditLogEntry, data: List[PromptOptionPayload] +) -> List[OnboardingPromptOption]: + return [OnboardingPromptOption.from_dict(data=option, state=entry._state, guild=entry.guild) for option in data] + + E = TypeVar('E', bound=enums.Enum) @@ -268,13 +280,15 @@ def _transform(entry: AuditLogEntry, data: Union[int, str]) -> F: def _transform_type( entry: AuditLogEntry, data: Union[int, str] -) -> Union[enums.ChannelType, enums.StickerType, enums.WebhookType, str]: +) -> Union[enums.ChannelType, enums.StickerType, enums.WebhookType, str, enums.OnboardingPromptType]: if entry.action.name.startswith('sticker_'): return enums.try_enum(enums.StickerType, data) elif entry.action.name.startswith('integration_'): return data # type: ignore # integration type is str elif entry.action.name.startswith('webhook_'): return enums.try_enum(enums.WebhookType, data) + elif entry.action.name.startswith('onboarding_prompt_'): + return enums.try_enum(enums.OnboardingPromptType, data) else: return enums.try_enum(enums.ChannelType, data) @@ -353,7 +367,11 @@ class AuditLogChanges: 'flags': (None, _transform_overloaded_flags), 'default_reaction_emoji': (None, _transform_default_reaction), 'emoji_name': ('emoji', _transform_default_emoji), - 'user_id': ('user', _transform_member_id) + 'user_id': ('user', _transform_member_id), + 'options': (None, _transform_onboarding_prompt_options), + 'prompts': (None, _transform_onboarding_prompts), + 'default_channel_ids': ('default_channels', _transform_channels_or_threads), + 'mode': (None, _enum_transformer(enums.OnboardingMode)), } # fmt: on @@ -977,3 +995,6 @@ def _convert_target_webhook(self, target_id: int) -> Union[Webhook, Object]: from .webhook import Webhook return self._webhooks.get(target_id) or Object(target_id, type=Webhook) + + def _convert_target_onboarding_prompt(self, target_id: int) -> Object: + return Object(target_id, type=OnboardingPrompt) diff --git a/discord/enums.py b/discord/enums.py index 1c978c7124ab..c5c944ca00f5 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -79,6 +79,8 @@ 'SubscriptionStatus', 'MessageReferenceType', 'StatusDisplayType', + 'OnboardingPromptType', + 'OnboardingMode', ) @@ -402,6 +404,13 @@ class AuditLogAction(Enum): automod_quarantine_user = 146 creator_monetization_request_created = 150 creator_monetization_terms_accepted = 151 + onboarding_prompt_create = 163 + onboarding_prompt_update = 164 + onboarding_prompt_delete = 165 + onboarding_create = 166 + onboarding_update = 167 + home_settings_create = 190 + home_settings_update = 191 # fmt: on @property @@ -468,6 +477,13 @@ def category(self) -> Optional[AuditLogActionCategory]: AuditLogAction.soundboard_sound_create: AuditLogActionCategory.create, AuditLogAction.soundboard_sound_update: AuditLogActionCategory.update, AuditLogAction.soundboard_sound_delete: AuditLogActionCategory.delete, + AuditLogAction.onboarding_prompt_create: AuditLogActionCategory.create, + AuditLogAction.onboarding_prompt_update: AuditLogActionCategory.update, + AuditLogAction.onboarding_prompt_delete: AuditLogActionCategory.delete, + AuditLogAction.onboarding_create: AuditLogActionCategory.create, + AuditLogAction.onboarding_update: AuditLogActionCategory.update, + AuditLogAction.home_settings_create: AuditLogActionCategory.create, + AuditLogAction.home_settings_update: AuditLogActionCategory.update, } # fmt: on return lookup[self] @@ -513,6 +529,12 @@ def target_type(self) -> Optional[str]: return 'user' elif v < 152: return 'creator_monetization' + elif v < 166: + return 'onboarding_prompt' + elif v < 168: + return 'onboarding' + elif v < 192: + return 'home_settings' class UserFlags(Enum): @@ -921,6 +943,16 @@ class StatusDisplayType(Enum): details = 2 +class OnboardingPromptType(Enum): + multiple_choice = 0 + dropdown = 1 + + +class OnboardingMode(Enum): + default = 0 + advanced = 1 + + def create_unknown_value(cls: Type[E], val: Any) -> E: value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below name = f'unknown_{val}' diff --git a/discord/guild.py b/discord/guild.py index 9506780a2752..06ce9cbb40c3 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -76,6 +76,7 @@ AutoModRuleEventType, ForumOrderType, ForumLayoutType, + OnboardingMode, ) from .mixins import Hashable from .user import User @@ -91,6 +92,7 @@ from .file import File from .audit_logs import AuditLogEntry from .object import OLDEST_OBJECT, Object +from .onboarding import Onboarding from .welcome_screen import WelcomeScreen, WelcomeChannel from .automod import AutoModRule, AutoModTrigger, AutoModRuleAction from .partial_emoji import _EmojiTag, PartialEmoji @@ -139,6 +141,7 @@ from .types.widget import EditWidgetSettings from .types.audit_log import AuditLogEvent from .message import EmojiInputType + from .onboarding import OnboardingPrompt VocalGuildChannel = Union[VoiceChannel, StageChannel] GuildChannel = Union[VocalGuildChannel, ForumChannel, TextChannel, CategoryChannel] @@ -4879,3 +4882,74 @@ async def create_soundboard_sound( data = await self._state.http.create_soundboard_sound(self.id, reason=reason, **payload) return SoundboardSound(guild=self, state=self._state, data=data) + + async def onboarding(self) -> Onboarding: + """|coro| + + Fetches the onboarding configuration for this guild. + + .. versionadded:: 2.6 + + Returns + -------- + :class:`Onboarding` + The onboarding configuration that was fetched. + """ + data = await self._state.http.get_guild_onboarding(self.id) + return Onboarding(data=data, guild=self, state=self._state) + + async def edit_onboarding( + self, + *, + prompts: List[OnboardingPrompt] = MISSING, + default_channels: List[Snowflake] = MISSING, + enabled: bool = MISSING, + mode: OnboardingMode = MISSING, + reason: str = MISSING, + ) -> Onboarding: + """|coro| + + Edits the onboarding configuration for this guild. + + You must have :attr:`Permissions.manage_guild` and + :attr:`Permissions.manage_roles` to do this. + + .. versionadded:: 2.6 + + Parameters + ----------- + prompts: List[:class:`OnboardingPrompt`] + The prompts that will be shown to new members. + This overrides the existing prompts and its options. + default_channels: List[:class:`abc.Snowflake`] + The channels that will be used as the default channels for new members. + This overrides the existing default channels. + enabled: :class:`bool` + Whether the onboarding configuration is enabled. + This overrides the existing enabled state. + mode: :class:`OnboardingMode` + The mode that will be used for the onboarding configuration. + reason: :class:`str` + The reason for editing the onboarding configuration. Shows up on the audit log. + + Raises + ------- + Forbidden + You do not have permissions to edit the onboarding configuration. + HTTPException + Editing the onboarding configuration failed. + + Returns + -------- + :class:`Onboarding` + The new onboarding configuration. + """ + data = await self._state.http.edit_guild_onboarding( + self.id, + prompts=[p.to_dict(id=i) for i, p in enumerate(prompts)] if prompts is not MISSING else None, + default_channel_ids=[c.id for c in default_channels] if default_channels is not MISSING else None, + enabled=enabled if enabled is not MISSING else None, + mode=mode.value if mode is not MISSING else None, + reason=reason if reason is not MISSING else None, + ) + return Onboarding(data=data, guild=self, state=self._state) diff --git a/discord/http.py b/discord/http.py index 02fd1e136db4..15de396d0b1a 100644 --- a/discord/http.py +++ b/discord/http.py @@ -81,6 +81,7 @@ invite, member, message, + onboarding, template, role, user, @@ -2541,6 +2542,42 @@ def delete_entitlement(self, application_id: Snowflake, entitlement_id: Snowflak ), ) + # Guild Onboarding + + def get_guild_onboarding(self, guild_id: Snowflake) -> Response[onboarding.Onboarding]: + return self.request(Route('GET', '/guilds/{guild_id}/onboarding', guild_id=guild_id)) + + def edit_guild_onboarding( + self, + guild_id: Snowflake, + *, + prompts: Optional[List[onboarding.Prompt]] = None, + default_channel_ids: Optional[List[Snowflake]] = None, + enabled: Optional[bool] = None, + mode: Optional[onboarding.OnboardingMode] = None, + reason: Optional[str], + ) -> Response[onboarding.Onboarding]: + + payload = {} + + if prompts is not None: + payload['prompts'] = prompts + + if default_channel_ids is not None: + payload['default_channel_ids'] = default_channel_ids + + if enabled is not None: + payload['enabled'] = enabled + + if mode is not None: + payload['mode'] = mode + + return self.request( + Route('PUT', f'/guilds/{guild_id}/onboarding', guild_id=guild_id), + json=payload, + reason=reason, + ) + # Soundboard def get_soundboard_default_sounds(self) -> Response[List[soundboard.SoundboardDefaultSound]]: diff --git a/discord/onboarding.py b/discord/onboarding.py new file mode 100644 index 000000000000..d26258c16935 --- /dev/null +++ b/discord/onboarding.py @@ -0,0 +1,369 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +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. +""" + +from __future__ import annotations +from typing import TYPE_CHECKING, Iterable, Optional, Set, List, Union + +from .mixins import Hashable +from .enums import OnboardingMode, OnboardingPromptType, try_enum +from .partial_emoji import PartialEmoji +from .utils import cached_slot_property, MISSING +from . import utils + +__all__ = ( + 'Onboarding', + 'OnboardingPrompt', + 'OnboardingPromptOption', +) + + +if TYPE_CHECKING: + from typing_extensions import Self + + from .abc import GuildChannel, Snowflake + from .emoji import Emoji + from .guild import Guild + from .partial_emoji import PartialEmoji + from .role import Role + from .threads import Thread + from .types.onboarding import ( + Prompt as PromptPayload, + PromptOption as PromptOptionPayload, + CreatePromptOption as CreatePromptOptionPayload, + Onboarding as OnboardingPayload, + ) + from .state import ConnectionState + + +class OnboardingPromptOption(Hashable): + """Represents a onboarding prompt option. + + This can be manually created for :meth:`Guild.edit_onboarding`. + + .. versionadded:: 2.6 + + Parameters + ----------- + title: :class:`str` + The title of this prompt option. + emoji: Union[:class:`Emoji`, :class:`PartialEmoji`, :class:`str`] + The emoji tied to this option. May be a custom emoji, or a unicode emoji. I + f this is a string, it will be converted to a :class:`PartialEmoji`. + description: Optional[:class:`str`] + The description of this prompt option. + channels: Iterable[Union[:class:`abc.Snowflake`, :class:`int`]] + The channels the user will be added to if this option is selected. + roles: Iterable[Union[:class:`abc.Snowflake`, :class:`int`]] + The roles the user will be given if this option is selected. + + Attributes + ----------- + id: :class:`int` + The ID of this prompt option. If this was manually created then the ID will be ``0``. + title: :class:`str` + The title of this prompt option. + description: Optional[:class:`str`] + The description of this prompt option. + emoji: Optional[Union[:class:`Emoji`, :class:`PartialEmoji`]] + The emoji tied to this option. May be a custom emoji, or a unicode emoji. + channel_ids: Set[:class:`int`] + The IDs of the channels the user will be added to if this option is selected. + role_ids: Set[:class:`int`] + The IDs of the roles the user will be given if this option is selected. + """ + + __slots__ = ( + 'title', + 'emoji', + 'description', + 'id', + 'channel_ids', + 'role_ids', + '_guild', + '_cs_channels', + '_cs_roles', + ) + + def __init__( + self, + *, + title: str, + emoji: Union[Emoji, PartialEmoji, str] = MISSING, + description: Optional[str] = None, + channels: Iterable[Union[Snowflake, int]] = MISSING, + roles: Iterable[Union[Snowflake, int]] = MISSING, + ) -> None: + self.id: int = 0 + self.title: str = title + self.description: Optional[str] = description + self.emoji: Optional[Union[Emoji, PartialEmoji]] = ( + PartialEmoji.from_str(emoji) if isinstance(emoji, str) else emoji if emoji is not MISSING else None + ) + + self.channel_ids: Set[int] = ( + {c.id if not isinstance(c, int) else c for c in channels} if channels is not MISSING else set() + ) + self.role_ids: Set[int] = {c.id if not isinstance(c, int) else c for c in roles} if roles is not MISSING else set() + self._guild: Optional[Guild] = None + + def __repr__(self) -> str: + return f'' + + @classmethod + def from_dict(cls, *, data: PromptOptionPayload, state: ConnectionState, guild: Guild) -> Self: + instance = cls( + title=data['title'], + description=data['description'], + emoji=state.get_emoji_from_partial_payload(data['emoji']) if 'emoji' in data else MISSING, + channels=[int(id) for id in data['channel_ids']], + roles=[int(id) for id in data['role_ids']], + ) + instance._guild = guild + instance.id = int(data['id']) + return instance + + def to_dict( + self, + ) -> CreatePromptOptionPayload: + res: CreatePromptOptionPayload = { + 'title': self.title, + 'description': self.description, + 'channel_ids': list(self.channel_ids), + 'role_ids': list(self.role_ids), + } + if self.emoji: + res.update((self.emoji._to_partial())._to_onboarding_prompt_option_payload()) # type: ignore + return res + + @property + def guild(self) -> Guild: + """:class:`Guild`: The guild this prompt option is related to. + + Raises + ------- + ValueError + If the prompt option was created manually. + """ + if self._guild is None: + raise ValueError('This prompt does not have an associated guild because it was created manually.') + return self._guild + + @cached_slot_property('_cs_channels') + def channels(self) -> List[Union[GuildChannel, Thread]]: + """List[Union[:class:`abc.GuildChannel`, :class:`Thread`]]: The list of channels which will be made visible if this option is selected. + + Raises + ------- + ValueError + IF the prompt option is manually created, therefore has no guild. + """ + it = filter(None, map(self.guild._resolve_channel, self.channel_ids)) + return utils._unique(it) + + @cached_slot_property('_cs_roles') + def roles(self) -> List[Role]: + """List[:class:`Role`]: The list of roles given to the user if this option is selected. + + Raises + ------- + ValueError + If the prompt option is manually created, therefore has no guild. + """ + it = filter(None, map(self.guild.get_role, self.role_ids)) + return utils._unique(it) + + +class OnboardingPrompt: + """Represents a onboarding prompt. + + This can be manually created for :meth:`Guild.edit_onboarding`. + + .. versionadded:: 2.6 + + Parameters + ----------- + type: :class:`OnboardingPromptType` + The type of this prompt. + title: :class:`str` + The title of this prompt. + options: List[:class:`OnboardingPromptOption`] + The options of this prompt. + single_select: :class:`bool` + Whether this prompt is single select. + Defaults to ``True``. + required: :class:`bool` + Whether this prompt is required. + Defaults to ``True``. + in_onboarding: :class:`bool` + Whether this prompt is in the onboarding flow. + Defaults to ``True``. + + Attributes + ----------- + id: :class:`int` + The ID of this prompt. If this was manually created then the ID will be ``0``. + type: :class:`OnboardingPromptType` + The type of this prompt. + title: :class:`str` + The title of this prompt. + options: List[:class:`OnboardingPromptOption`] + The options of this prompt. + single_select: :class:`bool` + Whether this prompt is single select. + required: :class:`bool` + Whether this prompt is required. + in_onboarding: :class:`bool` + Whether this prompt is in the onboarding flow. + """ + + __slots__ = ( + 'id', + 'type', + 'title', + 'options', + 'single_select', + 'required', + 'in_onboarding', + '_guild', + ) + + def __init__( + self, + *, + type: OnboardingPromptType, + title: str, + options: List[OnboardingPromptOption], + single_select: bool = True, + required: bool = True, + in_onboarding: bool = True, + ) -> None: + self.id: int = 0 + self.type: OnboardingPromptType = type + self.title: str = title + self.options: List[OnboardingPromptOption] = options + self.single_select: bool = single_select + self.required: bool = required + self.in_onboarding: bool = in_onboarding + + self._guild: Optional[Guild] = None + + def __repr__(self) -> str: + return f'' + + @classmethod + def from_dict(cls, *, data: PromptPayload, state: ConnectionState, guild: Guild) -> Self: + instance = cls( + type=try_enum(OnboardingPromptType, data['type']), + title=data['title'], + options=[ + OnboardingPromptOption.from_dict(data=option_data, state=state, guild=guild) # type: ignore + for option_data in data['options'] + ], + single_select=data['single_select'], + required=data['required'], + in_onboarding=data['in_onboarding'], + ) + instance.id = int(data['id']) + return instance + + def to_dict(self, *, id: int) -> PromptPayload: + return { + 'id': id, + 'type': self.type.value, + 'title': self.title, + 'options': [option.to_dict() for option in self.options], + 'single_select': self.single_select, + 'required': self.required, + 'in_onboarding': self.in_onboarding, + } + + @property + def guild(self) -> Guild: + """:class:`Guild`: The guild this prompt is related to. + + Raises + ------ + ValueError + If the prompt was created manually. + """ + if self._guild is None: + raise ValueError('This prompt does not have an associated guild because it was created manually.') + return self._guild + + def get_option(self, option_id: int, /) -> Optional[OnboardingPromptOption]: + """Optional[:class:`OnboardingPromptOption`]: The option with the given ID, if found.""" + return next((option for option in self.options if option.id == option_id), None) + + +class Onboarding: + """Represents a guild's onboarding configuration. + + .. versionadded:: 2.6 + + Attributes + ----------- + guild: :class:`Guild` + The guild the onboarding configuration is for. + prompts: List[:class:`OnboardingPrompt`] + The list of prompts shown during the onboarding and customize community flows. + default_channel_ids: Set[:class:`int`] + The IDs of the channels exposed to a new user by default. + enabled: :class:`bool`: + Whether onboarding is enabled in this guild. + mode: :class:`OnboardingMode` + The mode of onboarding for this guild. + """ + + __slots__ = ( + '_state', + '_cs_default_channels', + 'guild', + 'prompts', + 'default_channel_ids', + 'enabled', + 'mode', + ) + + def __init__(self, *, data: OnboardingPayload, guild: Guild, state: ConnectionState) -> None: + self._state: ConnectionState = state + self.guild: Guild = guild + self.default_channel_ids: Set[int] = {int(channel_id) for channel_id in data['default_channel_ids']} + self.prompts: List[OnboardingPrompt] = [ + OnboardingPrompt.from_dict(data=prompt_data, state=state, guild=guild) for prompt_data in data['prompts'] + ] + self.enabled: bool = data['enabled'] + self.mode: OnboardingMode = try_enum(OnboardingMode, data.get('mode', 0)) + + def __repr__(self) -> str: + return f'' + + @cached_slot_property('_cs_default_channels') + def default_channels(self) -> List[Union[GuildChannel, Thread]]: + """List[Union[:class:`abc.GuildChannel`, :class:`Thread`]]: The list of channels exposed to a new user by default.""" + it = filter(None, map(self.guild._resolve_channel, self.default_channel_ids)) + return utils._unique(it) + + def get_prompt(self, prompt_id: int, /) -> Optional[OnboardingPrompt]: + """Optional[:class:`OnboardingPrompt`]: The prompt with the given ID, if found.""" + return next((prompt for prompt in self.prompts if prompt.id == prompt_id), None) diff --git a/discord/partial_emoji.py b/discord/partial_emoji.py index 7d366949c01f..5022023300a9 100644 --- a/discord/partial_emoji.py +++ b/discord/partial_emoji.py @@ -167,6 +167,12 @@ def _to_forum_tag_payload(self) -> Dict[str, Any]: return {'emoji_id': self.id, 'emoji_name': None} return {'emoji_id': None, 'emoji_name': self.name} + def _to_onboarding_prompt_option_payload(self) -> Dict[str, Any]: + if self.id is not None: + return {'emoji_id': self.id, 'emoji_name': self.name, 'emoji_animated': self.animated} + + return {'emoji_name': self.name} + @classmethod def with_state( cls, diff --git a/discord/reaction.py b/discord/reaction.py index 9fd933b0a57a..060447e134dc 100644 --- a/discord/reaction.py +++ b/discord/reaction.py @@ -102,7 +102,7 @@ class Reaction: def __init__(self, *, message: Message, data: ReactionPayload, emoji: Optional[Union[PartialEmoji, Emoji, str]] = None): self.message: Message = message - self.emoji: Union[PartialEmoji, Emoji, str] = emoji or message._state.get_reaction_emoji(data['emoji']) + self.emoji: Union[PartialEmoji, Emoji, str] = emoji or message._state.get_emoji_from_partial_payload(data['emoji']) self.count: int = data.get('count', 1) self.me: bool = data['me'] details = data.get('count_details', {}) diff --git a/discord/state.py b/discord/state.py index 223a099238a5..12e227dc553b 100644 --- a/discord/state.py +++ b/discord/state.py @@ -1792,7 +1792,7 @@ def _get_reaction_user(self, channel: MessageableChannel, user_id: int) -> Optio return channel.guild.get_member(user_id) return self.get_user(user_id) - def get_reaction_emoji(self, data: PartialEmojiPayload) -> Union[Emoji, PartialEmoji, str]: + def get_emoji_from_partial_payload(self, data: PartialEmojiPayload) -> Union[Emoji, PartialEmoji, str]: emoji_id = utils._get_as_snowflake(data, 'id') if not emoji_id: diff --git a/discord/types/audit_log.py b/discord/types/audit_log.py index cc4ad8363a27..e2c3c150306b 100644 --- a/discord/types/audit_log.py +++ b/discord/types/audit_log.py @@ -38,6 +38,7 @@ from .threads import Thread from .command import ApplicationCommand, ApplicationCommandPermissions from .automod import AutoModerationTriggerMetadata +from .onboarding import PromptOption, Prompt AuditLogEvent = Literal[ 1, @@ -100,6 +101,13 @@ 146, 150, 151, + 163, + 164, + 165, + 166, + 167, + 190, + 191, ] @@ -117,6 +125,7 @@ class _AuditLogChange_Str(TypedDict): 'tags', 'unicode_emoji', 'emoji_name', + 'title', ] new_value: str old_value: str @@ -164,6 +173,10 @@ class _AuditLogChange_Bool(TypedDict): 'available', 'archived', 'locked', + 'enabled', + 'single_select', + 'required', + 'in_onboarding', ] new_value: bool old_value: bool @@ -274,8 +287,8 @@ class _AuditLogChange_AppCommandPermissions(TypedDict): old_value: ApplicationCommandPermissions -class _AuditLogChange_AppliedTags(TypedDict): - key: Literal['applied_tags'] +class _AuditLogChange_SnowflakeList(TypedDict): + key: Literal['applied_tags', 'default_channel_ids'] new_value: List[Snowflake] old_value: List[Snowflake] @@ -298,6 +311,18 @@ class _AuditLogChange_TriggerMetadata(TypedDict): old_value: Optional[AutoModerationTriggerMetadata] +class _AuditLogChange_Prompts(TypedDict): + key: Literal['prompts'] + new_value: List[Prompt] + old_value: List[Prompt] + + +class _AuditLogChange_Options(TypedDict): + key: Literal['options'] + new_value: List[PromptOption] + old_value: List[PromptOption] + + class _AuditLogChange_RoleColours(TypedDict): key: Literal['colors'] new_value: RoleColours @@ -324,10 +349,12 @@ class _AuditLogChange_RoleColours(TypedDict): _AuditLogChange_Status, _AuditLogChange_EntityType, _AuditLogChange_AppCommandPermissions, - _AuditLogChange_AppliedTags, + _AuditLogChange_SnowflakeList, _AuditLogChange_AvailableTags, _AuditLogChange_DefaultReactionEmoji, _AuditLogChange_TriggerMetadata, + _AuditLogChange_Prompts, + _AuditLogChange_Options, _AuditLogChange_RoleColours, ] diff --git a/discord/types/onboarding.py b/discord/types/onboarding.py new file mode 100644 index 000000000000..64f9c45c89cc --- /dev/null +++ b/discord/types/onboarding.py @@ -0,0 +1,72 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +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. +""" + +from __future__ import annotations +from typing import TYPE_CHECKING, Literal, Optional, TypedDict, List, Union + +from .emoji import PartialEmoji +from .snowflake import Snowflake + +if TYPE_CHECKING: + from typing_extensions import NotRequired + + +PromptType = Literal[0, 1] +OnboardingMode = Literal[0, 1] + + +class _PromptOption(TypedDict): + channel_ids: List[Snowflake] + role_ids: List[Snowflake] + title: str + description: Optional[str] + + +class CreatePromptOption(_PromptOption): + emoji_id: NotRequired[Snowflake] + emoji_name: NotRequired[str] + emoji_animated: NotRequired[bool] + + +class PromptOption(_PromptOption): + id: Snowflake + emoji: NotRequired[PartialEmoji] + + +class Prompt(TypedDict): + id: Snowflake + options: List[Union[PromptOption, CreatePromptOption]] + title: str + single_select: bool + required: bool + in_onboarding: bool + type: PromptType + + +class Onboarding(TypedDict): + guild_id: Snowflake + prompts: List[Prompt] + default_channel_ids: List[Snowflake] + enabled: bool + mode: OnboardingMode diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index dbb3f1ed9e96..dc6557e55f8b 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -755,7 +755,7 @@ def allowed_mentions(self) -> Optional[AllowedMentions]: def get_reaction_emoji(self, data: PartialEmojiPayload) -> Union[PartialEmoji, Emoji, str]: if self._parent is not None: - return self._parent.get_reaction_emoji(data) + return self._parent.get_emoji_from_partial_payload(data) emoji_id = utils._get_as_snowflake(data, 'id') diff --git a/docs/api.rst b/docs/api.rst index d7014d374221..231ca0a4bdcf 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3120,6 +3120,104 @@ of :class:`enum.Enum`. .. versionadded:: 2.5 + .. attribute:: onboarding_prompt_create + + A guild onboarding prompt was created. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + a :class:`Object` with the ID of the prompt that the options belong to. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.type` + - :attr:`~AuditLogDiff.title` + - :attr:`~AuditLogDiff.options` + - :attr:`~AuditLogDiff.single_select` + - :attr:`~AuditLogDiff.required` + - :attr:`~AuditLogDiff.in_onboarding` + + .. versionadded:: 2.6 + + .. attribute:: onboarding_prompt_update + + A guild onboarding prompt was updated. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + a :class:`Object` with the ID of the prompt that the options belong to. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.type` + - :attr:`~AuditLogDiff.title` + - :attr:`~AuditLogDiff.options` + - :attr:`~AuditLogDiff.single_select` + - :attr:`~AuditLogDiff.required` + - :attr:`~AuditLogDiff.in_onboarding` + + .. versionadded:: 2.6 + + .. attribute:: onboarding_prompt_delete + + A guild onboarding prompt was deleted. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + a :class:`Object` with the ID of the prompt that the options belong to. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.type` + - :attr:`~AuditLogDiff.title` + - :attr:`~AuditLogDiff.options` + - :attr:`~AuditLogDiff.single_select` + - :attr:`~AuditLogDiff.required` + - :attr:`~AuditLogDiff.in_onboarding` + + .. versionadded:: 2.6 + + .. attribute:: onboarding_create + + The guild's onboarding configuration was created. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + always ``None``. Use :attr:`~AuditLogEntry.guild` to access the guild. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.enabled` + - :attr:`~AuditLogDiff.default_channels` + - :attr:`~AuditLogDiff.prompts` + - :attr:`~AuditLogDiff.mode` + + .. versionadded:: 2.6 + + .. attribute:: onboarding_update + + The guild's onboarding configuration was updated. + + When this is the action, the type of :attr:`~AuditLogEntry.target` is + always ``None``. Use :attr:`~AuditLogEntry.guild` to access the guild. + + Possible attributes for :class:`AuditLogDiff`: + + - :attr:`~AuditLogDiff.enabled` + - :attr:`~AuditLogDiff.default_channels` + - :attr:`~AuditLogDiff.prompts` + - :attr:`~AuditLogDiff.mode` + + .. versionadded:: 2.6 + + .. attribute:: home_settings_create + + The guild's server guide was created. + + .. versionadded:: 2.6 + + .. attribute:: home_settings_update + + The guild's server guide was updated. + + .. versionadded:: 2.6 + .. class:: AuditLogActionCategory Represents the category that the :class:`AuditLogAction` belongs to. @@ -3917,6 +4015,35 @@ of :class:`enum.Enum`. The details of the activity are displayed. +.. class:: OnboardingPromptType + + Represents the type of onboarding prompt. + + .. versionadded:: 2.6 + + .. attribute:: multiple_choice + + Prompt options are multiple choice. + + .. attribute:: dropdown + + Prompt options are displayed as a drop-down. + +.. class:: OnboardingMode + + Represents the onboarding constraint mode. + + .. versionadded:: 2.6 + + .. attribute:: default + + Only default channels count towards onboarding constraints. + + .. attribute:: advanced + + Default channels and questions count towards onboarding constraints. + + .. _discord-api-audit-logs: Audit Log Data @@ -4163,9 +4290,9 @@ AuditLogDiff .. attribute:: type - The type of channel, sticker, webhook or integration. + The type of channel, sticker, webhook, integration or onboarding prompt. - :type: Union[:class:`ChannelType`, :class:`StickerType`, :class:`WebhookType`, :class:`str`] + :type: Union[:class:`ChannelType`, :class:`StickerType`, :class:`WebhookType`, :class:`str`, :class:`OnboardingPromptType`] .. attribute:: topic @@ -4538,7 +4665,7 @@ AuditLogDiff .. attribute:: enabled - Whether the automod rule is active or not. + Whether guild onboarding or the automod rule is active or not. :type: :class:`bool` @@ -4558,7 +4685,7 @@ AuditLogDiff The trigger for the automod rule. - .. note :: + .. note:: The :attr:`~AutoModTrigger.type` of the trigger may be incorrect. Some attributes such as :attr:`~AutoModTrigger.keyword_filter`, :attr:`~AutoModTrigger.regex_patterns`, @@ -4570,7 +4697,7 @@ AuditLogDiff The actions to take when an automod rule is triggered. - :type: List[AutoModRuleAction] + :type: List[:class:`AutoModRuleAction`] .. attribute:: exempt_roles @@ -4668,6 +4795,71 @@ AuditLogDiff :type: :class:`float` + .. attribute:: options + + The onboarding prompt options associated with this onboarding prompt. + + See also :attr:`OnboardingPrompt.options` + + :type: List[:class:`OnboardingPromptOption`] + + .. attribute:: default_channels + + The default channels associated with the onboarding in this guild. + + See also :attr:`Onboarding.default_channels` + + :type: List[:class:`abc.GuildChannel`, :class:`Object`] + + .. attribute:: prompts + + The onboarding prompts associated with the onboarding in this guild. + + See also :attr:`Onboarding.prompts` + + :type: List[:class:`OnboardingPrompt`] + + .. attribute:: title + + The title of the onboarding prompt. + + See also :attr:`OnboardingPrompt.title` + + :type: :class:`str` + + .. attribute:: single_select + + Whether only one prompt option can be selected. + + See also :attr:`OnboardingPrompt.single_select` + + :type: :class:`bool` + + .. attribute:: required + + Whether the onboarding prompt is required to complete the onboarding. + + See also :attr:`OnboardingPrompt.required` + + :type: :class:`bool` + + .. attribute:: in_onboarding + + Whether this prompt is currently part of the onboarding flow. + + See also :attr:`OnboardingPrompt.in_onboarding` + + :type: :class:`bool` + + .. attribute:: mode + + The onboarding constraint mode. + + See also :attr:`Onboarding.mode` + + :type: :class:`OnboardingMode` + + .. this is currently missing the following keys: reason and application_id I'm not sure how to port these @@ -5291,6 +5483,31 @@ GuildSticker .. autoclass:: GuildSticker() :members: +Onboarding +~~~~~~~~~~~ + +.. attributetable:: Onboarding + +.. autoclass:: Onboarding() + :members: + +OnboardingPrompt +~~~~~~~~~~~~~~~~~ + +.. attributetable:: OnboardingPrompt + +.. autoclass:: OnboardingPrompt() + :members: + + +OnboardingPromptOption +~~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: OnboardingPromptOption + +.. autoclass:: OnboardingPromptOption() + :members: + BaseSoundboardSound ~~~~~~~~~~~~~~~~~~~~~~~ From db42eba4fa1846a28578402d751e1651766a121a Mon Sep 17 00:00:00 2001 From: Sacul Date: Fri, 8 Aug 2025 15:11:29 +0800 Subject: [PATCH 050/138] Add more attributes to AppCommandChannel/Thread --- discord/app_commands/models.py | 96 ++++++++++++++++++++++++++++++++-- discord/types/interactions.py | 7 +++ 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/discord/app_commands/models.py b/discord/app_commands/models.py index 5851e7d8cce1..0b7a697da498 100644 --- a/discord/app_commands/models.py +++ b/discord/app_commands/models.py @@ -37,6 +37,7 @@ Locale, try_enum, ) +import array from ..mixins import Hashable from ..utils import _get_as_snowflake, parse_time, snowflake_time, MISSING from ..object import Object @@ -84,7 +85,7 @@ def is_app_command_argument_type(value: int) -> bool: from ..abc import Snowflake from ..state import ConnectionState from ..guild import GuildChannel, Guild - from ..channel import TextChannel + from ..channel import TextChannel, ForumChannel, ForumTag from ..threads import Thread from ..user import User @@ -719,6 +720,14 @@ def mention(self) -> str: """:class:`str`: The string that allows you to mention the channel.""" return f'<#{self.id}>' + @property + def jump_url(self) -> str: + """:class:`str`: Returns a URL that allows the client to jump to the channel. + + .. versionadded:: 2.6 + """ + return f'https://discord.com/channels/{self.guild_id}/{self.id}' + @property def created_at(self) -> datetime: """:class:`datetime.datetime`: An aware timestamp of when this channel was created in UTC.""" @@ -758,6 +767,30 @@ class AppCommandThread(Hashable): The name of the thread. parent_id: :class:`int` The parent text channel ID this thread belongs to. + owner_id: :class:`int` + The user's ID that created this thread. + + .. versionadded:: 2.6 + last_message_id: Optional[:class:`int`] + The last message ID of the message sent to this thread. It may + *not* point to an existing or valid message. + + .. versionadded:: 2.6 + slowmode_delay: :class:`int` + The number of seconds a member must wait between sending messages + in this thread. A value of ``0`` denotes that it is disabled. + Bots and users with :attr:`~discord.Permissions.manage_channels` or + :attr:`~discord.Permissions.manage_messages` bypass slowmode. + + .. versionadded:: 2.6 + message_count: :class:`int` + An approximate number of messages in this thread. + + .. versionadded:: 2.6 + member_count: :class:`int` + An approximate number of members in this thread. This caps at 50. + + .. versionadded:: 2.6 permissions: :class:`~discord.Permissions` The resolved permissions of the user who invoked the application command in that thread. @@ -792,6 +825,13 @@ class AppCommandThread(Hashable): 'archive_timestamp', 'locked', 'invitable', + 'owner_id', + 'message_count', + 'member_count', + 'slowmode_delay', + 'last_message_id', + '_applied_tags', + '_flags', '_created_at', '_state', ) @@ -810,6 +850,13 @@ def __init__( self.type: ChannelType = try_enum(ChannelType, data['type']) self.name: str = data['name'] self.permissions: Permissions = Permissions(int(data['permissions'])) + self.owner_id: int = int(data['owner_id']) + self.member_count: int = int(data['member_count']) + self.message_count: int = int(data['message_count']) + self.last_message_id: Optional[int] = _get_as_snowflake(data, 'last_message_id') + self.slowmode_delay: int = data.get('rate_limit_per_user', 0) + self._applied_tags: array.array[int] = array.array('Q', map(int, data.get('applied_tags', []))) + self._flags: int = data.get('flags', 0) self._unroll_metadata(data['thread_metadata']) def __str__(self) -> str: @@ -833,15 +880,58 @@ def _unroll_metadata(self, data: ThreadMetadata) -> None: self._created_at: Optional[datetime] = parse_time(data.get('create_timestamp')) @property - def parent(self) -> Optional[TextChannel]: - """Optional[:class:`~discord.TextChannel`]: The parent channel this thread belongs to.""" + def applied_tags(self) -> List[ForumTag]: + """List[:class:`~discord.ForumTag`]: A list of tags applied to this thread. + + .. versionadded:: 2.6 + """ + tags = [] + if self.parent is None or self.parent.type not in (ChannelType.forum, ChannelType.media): + return tags + + parent = self.parent + for tag_id in self._applied_tags: + tag = parent.get_tag(tag_id) # type: ignore # parent here will be ForumChannel instance + if tag is not None: + tags.append(tag) + + return tags + + @property + def parent(self) -> Optional[Union[ForumChannel, TextChannel]]: + """Optional[Union[:class:`~discord.ForumChannel`, :class:`~discord.TextChannel`]]: The parent channel + this thread belongs to.""" return self.guild.get_channel(self.parent_id) # type: ignore + @property + def flags(self) -> ChannelFlags: + """:class:`~discord.ChannelFlags`: The flags associated with this thread. + + .. versionadded:: 2.6 + """ + return ChannelFlags._from_value(self._flags) + + @property + def owner(self) -> Optional[Member]: + """Optional[:class:`~discord.Member`]: The member this thread belongs to. + + .. versionadded:: 2.6 + """ + return self.guild.get_member(self.owner_id) # type: ignore + @property def mention(self) -> str: """:class:`str`: The string that allows you to mention the thread.""" return f'<#{self.id}>' + @property + def jump_url(self) -> str: + """:class:`str`: Returns a URL that allows the client to jump to the thread. + + .. versionadded:: 2.6 + """ + return f'https://discord.com/channels/{self.guild_id}/{self.id}' + @property def created_at(self) -> Optional[datetime]: """An aware timestamp of when the thread was created in UTC. diff --git a/discord/types/interactions.py b/discord/types/interactions.py index 464f2445fd1c..dc9971a1f629 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -78,6 +78,13 @@ class PartialThread(_BasePartialChannel): type: ThreadType thread_metadata: ThreadMetadata parent_id: Snowflake + applied_tags: NotRequired[List[Snowflake]] + owner_id: Snowflake + message_count: int + member_count: int + rate_limit_per_user: int + last_message_id: NotRequired[Optional[Snowflake]] + flags: NotRequired[int] class ResolvedData(TypedDict, total=False): From ec409a0a7b4642dad4de31731b4fa3e0eb7bdc83 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 8 Aug 2025 03:13:30 -0400 Subject: [PATCH 051/138] Guard AppCommandThread.guild attribute access --- discord/app_commands/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/app_commands/models.py b/discord/app_commands/models.py index 0b7a697da498..1dd004f1dcb1 100644 --- a/discord/app_commands/models.py +++ b/discord/app_commands/models.py @@ -901,7 +901,7 @@ def applied_tags(self) -> List[ForumTag]: def parent(self) -> Optional[Union[ForumChannel, TextChannel]]: """Optional[Union[:class:`~discord.ForumChannel`, :class:`~discord.TextChannel`]]: The parent channel this thread belongs to.""" - return self.guild.get_channel(self.parent_id) # type: ignore + return self.guild and self.guild.get_channel(self.parent_id) # type: ignore @property def flags(self) -> ChannelFlags: @@ -917,7 +917,7 @@ def owner(self) -> Optional[Member]: .. versionadded:: 2.6 """ - return self.guild.get_member(self.owner_id) # type: ignore + return self.guild and self.guild.get_member(self.owner_id) @property def mention(self) -> str: From 6ec2e5329b1059a80adf408dc08b6955a49f318b Mon Sep 17 00:00:00 2001 From: Sacul Date: Tue, 12 Aug 2025 15:18:24 +0800 Subject: [PATCH 052/138] Deprecate with_expiration param in fetch_invite --- discord/client.py | 4 +++- discord/http.py | 2 -- discord/invite.py | 5 +++-- discord/types/gateway.py | 1 + discord/types/invite.py | 3 ++- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/discord/client.py b/discord/client.py index 4f16e6ff5bbf..56f25623193b 100644 --- a/discord/client.py +++ b/discord/client.py @@ -2510,6 +2510,9 @@ async def fetch_invite( :attr:`.Invite.expires_at` field. .. versionadded:: 2.0 + .. deprecated:: 2.6 + This parameter is deprecated and will be removed in a future version as it is no + longer needed to fill the :attr:`.Invite.expires_at` field. scheduled_event_id: Optional[:class:`int`] The ID of the scheduled event this invite is for. @@ -2545,7 +2548,6 @@ async def fetch_invite( data = await self.http.get_invite( resolved.code, with_counts=with_counts, - with_expiration=with_expiration, guild_scheduled_event_id=scheduled_event_id, ) return Invite.from_incomplete(state=self._connection, data=data) diff --git a/discord/http.py b/discord/http.py index 15de396d0b1a..41927c6835c1 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1864,12 +1864,10 @@ def get_invite( invite_id: str, *, with_counts: bool = True, - with_expiration: bool = True, guild_scheduled_event_id: Optional[Snowflake] = None, ) -> Response[invite.Invite]: params: Dict[str, Any] = { 'with_counts': int(with_counts), - 'with_expiration': int(with_expiration), } if guild_scheduled_event_id: diff --git a/discord/invite.py b/discord/invite.py index 362f976931d4..38ee45901d0d 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -290,8 +290,6 @@ class Invite(Hashable): +------------------------------------+--------------------------------------------------------------+ | :attr:`approximate_presence_count` | :meth:`Client.fetch_invite` with ``with_counts`` enabled | +------------------------------------+--------------------------------------------------------------+ - | :attr:`expires_at` | :meth:`Client.fetch_invite` with ``with_expiration`` enabled | - +------------------------------------+--------------------------------------------------------------+ If it's not in the table above then it is available by all methods. @@ -332,6 +330,9 @@ class Invite(Hashable): :meth:`Client.fetch_invite` with ``with_expiration`` enabled, the invite will never expire. .. versionadded:: 2.0 + .. versionchanged:: 2.6 + This will always be returned from all methods. ``None`` if the invite will + never expire. channel: Optional[Union[:class:`abc.GuildChannel`, :class:`Object`, :class:`PartialInviteChannel`]] The channel the invite is for. diff --git a/discord/types/gateway.py b/discord/types/gateway.py index 7dca5badc356..61959dc720a7 100644 --- a/discord/types/gateway.py +++ b/discord/types/gateway.py @@ -145,6 +145,7 @@ class InviteCreateEvent(TypedDict): code: str created_at: str max_age: int + expires_at: Optional[str] max_uses: int temporary: bool uses: Literal[0] diff --git a/discord/types/invite.py b/discord/types/invite.py index 47c9729941b3..06bd6649b89a 100644 --- a/discord/types/invite.py +++ b/discord/types/invite.py @@ -44,7 +44,6 @@ class _InviteMetadata(TypedDict, total=False): max_age: int temporary: bool created_at: str - expires_at: Optional[str] class VanityInvite(_InviteMetadata): @@ -66,6 +65,7 @@ class Invite(IncompleteInvite, total=False): guild_scheduled_event: GuildScheduledEvent type: InviteType flags: NotRequired[int] + expires_at: Optional[str] class InviteWithCounts(Invite, _GuildPreviewUnique): @@ -76,6 +76,7 @@ class GatewayInviteCreate(TypedDict): channel_id: Snowflake code: str created_at: str + expires_at: Optional[str] max_age: int max_uses: int temporary: bool From 50caa3c82cf44eb5c9a2784f5d00ec2de7d51929 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 14 Aug 2025 02:37:23 +0200 Subject: [PATCH 053/138] Add support for components V2 Co-authored-by: Michael H Co-authored-by: Soheab <33902984+Soheab@users.noreply.github.com> Co-authored-by: owocado <24418520+owocado@users.noreply.github.com> Co-authored-by: Jay3332 <40323796+jay3332@users.noreply.github.com> Co-authored-by: Danny <1695103+Rapptz@users.noreply.github.com> --- discord/abc.py | 40 +- discord/channel.py | 49 ++- discord/client.py | 12 +- discord/components.py | 702 +++++++++++++++++++++++++++++++- discord/enums.py | 21 + discord/ext/commands/context.py | 74 +++- discord/flags.py | 10 + discord/http.py | 12 +- discord/interactions.py | 99 ++++- discord/message.py | 121 +++--- discord/state.py | 8 +- discord/types/components.py | 94 ++++- discord/types/message.py | 6 +- discord/ui/__init__.py | 8 + discord/ui/action_row.py | 585 ++++++++++++++++++++++++++ discord/ui/button.py | 71 +++- discord/ui/container.py | 369 +++++++++++++++++ discord/ui/dynamic.py | 15 +- discord/ui/file.py | 146 +++++++ discord/ui/item.py | 68 +++- discord/ui/media_gallery.py | 260 ++++++++++++ discord/ui/section.py | 248 +++++++++++ discord/ui/select.py | 105 ++++- discord/ui/separator.py | 124 ++++++ discord/ui/text_display.py | 89 ++++ discord/ui/text_input.py | 9 + discord/ui/thumbnail.py | 132 ++++++ discord/ui/view.py | 541 ++++++++++++++++++------ discord/webhook/async_.py | 96 ++++- discord/webhook/sync.py | 83 +++- docs/api.rst | 50 ++- docs/interactions/api.rst | 218 +++++++++- examples/views/layout.py | 47 +++ 33 files changed, 4214 insertions(+), 298 deletions(-) create mode 100644 discord/ui/action_row.py create mode 100644 discord/ui/container.py create mode 100644 discord/ui/file.py create mode 100644 discord/ui/media_gallery.py create mode 100644 discord/ui/section.py create mode 100644 discord/ui/separator.py create mode 100644 discord/ui/text_display.py create mode 100644 discord/ui/thumbnail.py create mode 100644 examples/views/layout.py diff --git a/discord/abc.py b/discord/abc.py index 713398a7db4d..a56451da6a11 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -96,7 +96,7 @@ ) from .poll import Poll from .threads import Thread - from .ui.view import View + from .ui.view import BaseView, View, LayoutView from .types.channel import ( PermissionOverwrite as PermissionOverwritePayload, Channel as ChannelPayload, @@ -1386,6 +1386,38 @@ class Messageable: async def _get_channel(self) -> MessageableChannel: raise NotImplementedError + @overload + async def send( + self, + *, + file: File = ..., + delete_after: float = ..., + nonce: Union[str, int] = ..., + allowed_mentions: AllowedMentions = ..., + reference: Union[Message, MessageReference, PartialMessage] = ..., + mention_author: bool = ..., + view: LayoutView, + suppress_embeds: bool = ..., + silent: bool = ..., + ) -> Message: + ... + + @overload + async def send( + self, + *, + files: Sequence[File] = ..., + delete_after: float = ..., + nonce: Union[str, int] = ..., + allowed_mentions: AllowedMentions = ..., + reference: Union[Message, MessageReference, PartialMessage] = ..., + mention_author: bool = ..., + view: LayoutView, + suppress_embeds: bool = ..., + silent: bool = ..., + ) -> Message: + ... + @overload async def send( self, @@ -1485,7 +1517,7 @@ async def send( allowed_mentions: Optional[AllowedMentions] = None, reference: Optional[Union[Message, MessageReference, PartialMessage]] = None, mention_author: Optional[bool] = None, - view: Optional[View] = None, + view: Optional[BaseView] = None, suppress_embeds: bool = False, silent: bool = False, poll: Optional[Poll] = None, @@ -1558,7 +1590,7 @@ async def send( If set, overrides the :attr:`~discord.AllowedMentions.replied_user` attribute of ``allowed_mentions``. .. versionadded:: 1.6 - view: :class:`discord.ui.View` + view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`] A Discord UI View to add to the message. .. versionadded:: 2.0 @@ -1656,7 +1688,7 @@ async def send( data = await state.http.send_message(channel.id, params=params) ret = state.create_message(channel=channel, data=data) - if view and not view.is_finished(): + if view and not view.is_finished() and view.is_dispatchable(): state.store_view(view, ret.id) if poll: diff --git a/discord/channel.py b/discord/channel.py index a306707d6fdb..de764f6f730a 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -100,7 +100,7 @@ from .file import File from .user import ClientUser, User, BaseUser from .guild import Guild, GuildChannel as GuildChannelType - from .ui.view import View + from .ui.view import BaseView, View, LayoutView from .types.channel import ( TextChannel as TextChannelPayload, NewsChannel as NewsChannelPayload, @@ -2841,6 +2841,47 @@ async def create_tag( return result + @overload + async def create_thread( + self, + *, + name: str, + auto_archive_duration: ThreadArchiveDuration = ..., + slowmode_delay: Optional[int] = ..., + file: File = ..., + files: Sequence[File] = ..., + allowed_mentions: AllowedMentions = ..., + mention_author: bool = ..., + applied_tags: Sequence[ForumTag] = ..., + view: LayoutView, + suppress_embeds: bool = ..., + reason: Optional[str] = ..., + ) -> ThreadWithMessage: + ... + + @overload + async def create_thread( + self, + *, + name: str, + auto_archive_duration: ThreadArchiveDuration = ..., + slowmode_delay: Optional[int] = ..., + content: Optional[str] = ..., + tts: bool = ..., + embed: Embed = ..., + embeds: Sequence[Embed] = ..., + file: File = ..., + files: Sequence[File] = ..., + stickers: Sequence[Union[GuildSticker, StickerItem]] = ..., + allowed_mentions: AllowedMentions = ..., + mention_author: bool = ..., + applied_tags: Sequence[ForumTag] = ..., + view: View = ..., + suppress_embeds: bool = ..., + reason: Optional[str] = ..., + ) -> ThreadWithMessage: + ... + async def create_thread( self, *, @@ -2857,7 +2898,7 @@ async def create_thread( allowed_mentions: AllowedMentions = MISSING, mention_author: bool = MISSING, applied_tags: Sequence[ForumTag] = MISSING, - view: View = MISSING, + view: BaseView = MISSING, suppress_embeds: bool = False, reason: Optional[str] = None, ) -> ThreadWithMessage: @@ -2907,7 +2948,7 @@ async def create_thread( If set, overrides the :attr:`~discord.AllowedMentions.replied_user` attribute of ``allowed_mentions``. applied_tags: List[:class:`discord.ForumTag`] A list of tags to apply to the thread. - view: :class:`discord.ui.View` + view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`] A Discord UI View to add to the message. stickers: Sequence[Union[:class:`~discord.GuildSticker`, :class:`~discord.StickerItem`]] A list of stickers to upload. Must be a maximum of 3. @@ -2983,7 +3024,7 @@ async def create_thread( data = await state.http.start_thread_in_forum(self.id, params=params, reason=reason) thread = Thread(guild=self.guild, state=self._state, data=data) message = Message(state=self._state, channel=thread, data=data['message']) - if view and not view.is_finished(): + if view and not view.is_finished() and view.is_dispatchable(): self._state.store_view(view, message.id) return ThreadWithMessage(thread=thread, message=message) diff --git a/discord/client.py b/discord/client.py index 56f25623193b..e80d17454864 100644 --- a/discord/client.py +++ b/discord/client.py @@ -72,7 +72,7 @@ from .backoff import ExponentialBackoff from .webhook import Webhook from .appinfo import AppInfo -from .ui.view import View +from .ui.view import BaseView from .ui.dynamic import DynamicItem from .stage_instance import StageInstance from .threads import Thread @@ -3156,7 +3156,7 @@ def remove_dynamic_items(self, *items: Type[DynamicItem[Item[Any]]]) -> None: self._connection.remove_dynamic_items(*items) - def add_view(self, view: View, *, message_id: Optional[int] = None) -> None: + def add_view(self, view: BaseView, *, message_id: Optional[int] = None) -> None: """Registers a :class:`~discord.ui.View` for persistent listening. This method should be used for when a view is comprised of components @@ -3166,7 +3166,7 @@ def add_view(self, view: View, *, message_id: Optional[int] = None) -> None: Parameters ------------ - view: :class:`discord.ui.View` + view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`] The view to register for dispatching. message_id: Optional[:class:`int`] The message ID that the view is attached to. This is currently used to @@ -3182,7 +3182,7 @@ def add_view(self, view: View, *, message_id: Optional[int] = None) -> None: and all their components have an explicitly provided custom_id. """ - if not isinstance(view, View): + if not isinstance(view, BaseView): raise TypeError(f'expected an instance of View not {view.__class__.__name__}') if not view.is_persistent(): @@ -3194,8 +3194,8 @@ def add_view(self, view: View, *, message_id: Optional[int] = None) -> None: self._connection.store_view(view, message_id) @property - def persistent_views(self) -> Sequence[View]: - """Sequence[:class:`.View`]: A sequence of persistent views added to the client. + def persistent_views(self) -> Sequence[BaseView]: + """Sequence[Union[:class:`.View`, :class:`.LayoutView`]]: A sequence of persistent views added to the client. .. versionadded:: 2.0 """ diff --git a/discord/components.py b/discord/components.py index b62ab6bf9e08..00e5db0154d3 100644 --- a/discord/components.py +++ b/discord/components.py @@ -24,9 +24,30 @@ from __future__ import annotations -from typing import ClassVar, List, Literal, Optional, TYPE_CHECKING, Tuple, Union, overload -from .enums import try_enum, ComponentType, ButtonStyle, TextStyle, ChannelType, SelectDefaultValueType -from .utils import get_slots, MISSING +from typing import ( + ClassVar, + List, + Literal, + Optional, + TYPE_CHECKING, + Tuple, + Union, +) + +from .asset import AssetMixin +from .enums import ( + try_enum, + ComponentType, + ButtonStyle, + TextStyle, + ChannelType, + SelectDefaultValueType, + SeparatorSpacing, + MediaItemLoadingState, +) +from .flags import AttachmentFlags +from .colour import Colour +from .utils import get_slots, MISSING, _get_as_snowflake from .partial_emoji import PartialEmoji, _EmojiTag if TYPE_CHECKING: @@ -39,13 +60,35 @@ SelectOption as SelectOptionPayload, ActionRow as ActionRowPayload, TextInput as TextInputPayload, - ActionRowChildComponent as ActionRowChildComponentPayload, SelectDefaultValues as SelectDefaultValuesPayload, + SectionComponent as SectionComponentPayload, + TextComponent as TextComponentPayload, + MediaGalleryComponent as MediaGalleryComponentPayload, + FileComponent as FileComponentPayload, + SeparatorComponent as SeparatorComponentPayload, + MediaGalleryItem as MediaGalleryItemPayload, + ThumbnailComponent as ThumbnailComponentPayload, + ContainerComponent as ContainerComponentPayload, + UnfurledMediaItem as UnfurledMediaItemPayload, ) + from .emoji import Emoji from .abc import Snowflake + from .state import ConnectionState ActionRowChildComponentType = Union['Button', 'SelectMenu', 'TextInput'] + SectionComponentType = Union['TextDisplay'] + MessageComponentType = Union[ + ActionRowChildComponentType, + SectionComponentType, + 'ActionRow', + 'SectionComponent', + 'ThumbnailComponent', + 'MediaGalleryComponent', + 'FileComponent', + 'SectionComponent', + 'Component', + ] __all__ = ( @@ -56,18 +99,35 @@ 'SelectOption', 'TextInput', 'SelectDefaultValue', + 'SectionComponent', + 'ThumbnailComponent', + 'UnfurledMediaItem', + 'MediaGalleryItem', + 'MediaGalleryComponent', + 'FileComponent', + 'SectionComponent', + 'Container', + 'TextDisplay', + 'SeparatorComponent', ) class Component: """Represents a Discord Bot UI Kit Component. - Currently, the only components supported by Discord are: + The components supported by Discord are: - :class:`ActionRow` - :class:`Button` - :class:`SelectMenu` - :class:`TextInput` + - :class:`SectionComponent` + - :class:`TextDisplay` + - :class:`ThumbnailComponent` + - :class:`MediaGalleryComponent` + - :class:`FileComponent` + - :class:`SeparatorComponent` + - :class:`Container` This class is abstract and cannot be instantiated. @@ -116,20 +176,25 @@ class ActionRow(Component): ------------ children: List[Union[:class:`Button`, :class:`SelectMenu`, :class:`TextInput`]] The children components that this holds, if any. + id: Optional[:class:`int`] + The ID of this component. + + .. versionadded:: 2.6 """ - __slots__: Tuple[str, ...] = ('children',) + __slots__: Tuple[str, ...] = ('children', 'id') __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ def __init__(self, data: ActionRowPayload, /) -> None: + self.id: Optional[int] = data.get('id') self.children: List[ActionRowChildComponentType] = [] for component_data in data.get('components', []): component = _component_factory(component_data) if component is not None: - self.children.append(component) + self.children.append(component) # type: ignore # should be the correct type here @property def type(self) -> Literal[ComponentType.action_row]: @@ -137,10 +202,13 @@ def type(self) -> Literal[ComponentType.action_row]: return ComponentType.action_row def to_dict(self) -> ActionRowPayload: - return { + payload: ActionRowPayload = { 'type': self.type.value, 'components': [child.to_dict() for child in self.children], } + if self.id is not None: + payload['id'] = self.id + return payload class Button(Component): @@ -174,6 +242,10 @@ class Button(Component): The SKU ID this button sends you to, if available. .. versionadded:: 2.4 + id: Optional[:class:`int`] + The ID of this component. + + .. versionadded:: 2.6 """ __slots__: Tuple[str, ...] = ( @@ -184,11 +256,13 @@ class Button(Component): 'label', 'emoji', 'sku_id', + 'id', ) __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ def __init__(self, data: ButtonComponentPayload, /) -> None: + self.id: Optional[int] = data.get('id') self.style: ButtonStyle = try_enum(ButtonStyle, data['style']) self.custom_id: Optional[str] = data.get('custom_id') self.url: Optional[str] = data.get('url') @@ -217,6 +291,9 @@ def to_dict(self) -> ButtonComponentPayload: 'disabled': self.disabled, } + if self.id is not None: + payload['id'] = self.id + if self.sku_id: payload['sku_id'] = str(self.sku_id) @@ -268,6 +345,10 @@ class SelectMenu(Component): Whether the select is disabled or not. channel_types: List[:class:`.ChannelType`] A list of channel types that are allowed to be chosen in this select menu. + id: Optional[:class:`int`] + The ID of this component. + + .. versionadded:: 2.6 """ __slots__: Tuple[str, ...] = ( @@ -280,6 +361,7 @@ class SelectMenu(Component): 'disabled', 'channel_types', 'default_values', + 'id', ) __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ @@ -296,6 +378,7 @@ def __init__(self, data: SelectMenuPayload, /) -> None: self.default_values: List[SelectDefaultValue] = [ SelectDefaultValue.from_dict(d) for d in data.get('default_values', []) ] + self.id: Optional[int] = data.get('id') def to_dict(self) -> SelectMenuPayload: payload: SelectMenuPayload = { @@ -305,6 +388,8 @@ def to_dict(self) -> SelectMenuPayload: 'max_values': self.max_values, 'disabled': self.disabled, } + if self.id is not None: + payload['id'] = self.id if self.placeholder: payload['placeholder'] = self.placeholder if self.options: @@ -312,7 +397,7 @@ def to_dict(self) -> SelectMenuPayload: if self.channel_types: payload['channel_types'] = [t.value for t in self.channel_types] if self.default_values: - payload["default_values"] = [v.to_dict() for v in self.default_values] + payload['default_values'] = [v.to_dict() for v in self.default_values] return payload @@ -473,6 +558,10 @@ class TextInput(Component): The minimum length of the text input. max_length: Optional[:class:`int`] The maximum length of the text input. + id: Optional[:class:`int`] + The ID of this component. + + .. versionadded:: 2.6 """ __slots__: Tuple[str, ...] = ( @@ -484,6 +573,7 @@ class TextInput(Component): 'required', 'min_length', 'max_length', + 'id', ) __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ @@ -497,6 +587,7 @@ def __init__(self, data: TextInputPayload, /) -> None: self.required: bool = data.get('required', True) self.min_length: Optional[int] = data.get('min_length') self.max_length: Optional[int] = data.get('max_length') + self.id: Optional[int] = data.get('id') @property def type(self) -> Literal[ComponentType.text_input]: @@ -512,6 +603,9 @@ def to_dict(self) -> TextInputPayload: 'required': self.required, } + if self.id is not None: + payload['id'] = self.id + if self.placeholder: payload['placeholder'] = self.placeholder @@ -645,17 +739,577 @@ def from_user(cls, user: Snowflake, /) -> Self: ) -@overload -def _component_factory(data: ActionRowChildComponentPayload) -> Optional[ActionRowChildComponentType]: - ... +class SectionComponent(Component): + """Represents a section from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type to create a section is :class:`discord.ui.Section` + not this one. + + .. versionadded:: 2.6 + + Attributes + ---------- + children: List[:class:`TextDisplay`] + The components on this section. + accessory: :class:`Component` + The section accessory. + id: Optional[:class:`int`] + The ID of this component. + """ + + __slots__ = ( + 'children', + 'accessory', + 'id', + ) + + __repr_info__ = __slots__ + + def __init__(self, data: SectionComponentPayload, state: Optional[ConnectionState]) -> None: + self.children: List[SectionComponentType] = [] + self.accessory: Component = _component_factory(data['accessory'], state) # type: ignore + self.id: Optional[int] = data.get('id') + + for component_data in data['components']: + component = _component_factory(component_data, state) + if component is not None: + self.children.append(component) # type: ignore # should be the correct type here + + @property + def type(self) -> Literal[ComponentType.section]: + return ComponentType.section + + def to_dict(self) -> SectionComponentPayload: + payload: SectionComponentPayload = { + 'type': self.type.value, + 'components': [c.to_dict() for c in self.children], + 'accessory': self.accessory.to_dict(), + } + + if self.id is not None: + payload['id'] = self.id + + return payload + + +class ThumbnailComponent(Component): + """Represents a Thumbnail from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type to create a thumbnail is :class:`discord.ui.Thumbnail` + not this one. + + .. versionadded:: 2.6 + Attributes + ---------- + media: :class:`UnfurledMediaItem` + The media for this thumbnail. + description: Optional[:class:`str`] + The description shown within this thumbnail. + spoiler: :class:`bool` + Whether this thumbnail is flagged as a spoiler. + id: Optional[:class:`int`] + The ID of this component. + """ + + __slots__ = ( + 'media', + 'spoiler', + 'description', + 'id', + ) + + __repr_info__ = __slots__ + + def __init__( + self, + data: ThumbnailComponentPayload, + state: Optional[ConnectionState], + ) -> None: + self.media: UnfurledMediaItem = UnfurledMediaItem._from_data(data['media'], state) + self.description: Optional[str] = data.get('description') + self.spoiler: bool = data.get('spoiler', False) + self.id: Optional[int] = data.get('id') + + @property + def type(self) -> Literal[ComponentType.thumbnail]: + return ComponentType.thumbnail + + def to_dict(self) -> ThumbnailComponentPayload: + payload = { + 'media': self.media.to_dict(), + 'description': self.description, + 'spoiler': self.spoiler, + 'type': self.type.value, + } + + if self.id is not None: + payload['id'] = self.id + + return payload # type: ignore + + +class TextDisplay(Component): + """Represents a text display from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type to create a text display is + :class:`discord.ui.TextDisplay` not this one. + + .. versionadded:: 2.6 + + Attributes + ---------- + content: :class:`str` + The content that this display shows. + id: Optional[:class:`int`] + The ID of this component. + """ + + __slots__ = ('content', 'id') + + __repr_info__ = __slots__ + + def __init__(self, data: TextComponentPayload) -> None: + self.content: str = data['content'] + self.id: Optional[int] = data.get('id') + + @property + def type(self) -> Literal[ComponentType.text_display]: + return ComponentType.text_display + + def to_dict(self) -> TextComponentPayload: + payload: TextComponentPayload = { + 'type': self.type.value, + 'content': self.content, + } + if self.id is not None: + payload['id'] = self.id + return payload + + +class UnfurledMediaItem(AssetMixin): + """Represents an unfurled media item. + + .. versionadded:: 2.6 + + Parameters + ---------- + url: :class:`str` + The URL of this media item. This can be an arbitrary url or a reference to a local + file uploaded as an attachment within the message, which can be accessed with the + ``attachment://`` format. + + Attributes + ---------- + url: :class:`str` + The URL of this media item. + proxy_url: Optional[:class:`str`] + The proxy URL. This is a cached version of the :attr:`.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. + height: Optional[:class:`int`] + The media item's height, in pixels. Only applicable to images and videos. + width: Optional[:class:`int`] + The media item's width, in pixels. Only applicable to images and videos. + content_type: Optional[:class:`str`] + The media item's `media type `_ + placeholder: Optional[:class:`str`] + The media item's placeholder. + loading_state: Optional[:class:`MediaItemLoadingState`] + The loading state of this media item. + attachment_id: Optional[:class:`int`] + The attachment id this media item points to, only available if the url points to a local file + uploaded within the component message. + """ + + __slots__ = ( + 'url', + 'proxy_url', + 'height', + 'width', + 'content_type', + '_flags', + 'placeholder', + 'loading_state', + 'attachment_id', + '_state', + ) + + def __init__(self, url: str) -> None: + self.url: str = url + + self.proxy_url: Optional[str] = None + self.height: Optional[int] = None + self.width: Optional[int] = None + self.content_type: Optional[str] = None + self._flags: int = 0 + self.placeholder: Optional[str] = None + self.loading_state: Optional[MediaItemLoadingState] = None + self.attachment_id: Optional[int] = None + self._state: Optional[ConnectionState] = None + + @property + def flags(self) -> AttachmentFlags: + """:class:`AttachmentFlags`: This media item's flags.""" + return AttachmentFlags._from_value(self._flags) + + @classmethod + def _from_data(cls, data: UnfurledMediaItemPayload, state: Optional[ConnectionState]): + self = cls(data['url']) + self._update(data, state) + return self + + def _update(self, data: UnfurledMediaItemPayload, state: Optional[ConnectionState]) -> None: + self.proxy_url = data.get('proxy_url') + self.height = data.get('height') + self.width = data.get('width') + self.content_type = data.get('content_type') + self._flags = data.get('flags', 0) + self.placeholder = data.get('placeholder') + + loading_state = data.get('loading_state') + if loading_state is not None: + self.loading_state = try_enum(MediaItemLoadingState, loading_state) + self.attachment_id = _get_as_snowflake(data, 'attachment_id') + self._state = state + + def __repr__(self) -> str: + return f'' + + def to_dict(self): + return { + 'url': self.url, + } + + +class MediaGalleryItem: + """Represents a :class:`MediaGalleryComponent` media item. + + .. versionadded:: 2.6 + + Parameters + ---------- + media: Union[:class:`str`, :class:`UnfurledMediaItem`] + The media item data. This can be a string representing a local + file uploaded as an attachment in the message, which can be accessed + using the ``attachment://`` format, or an arbitrary url. + description: Optional[:class:`str`] + The description to show within this item. Up to 256 characters. Defaults + to ``None``. + spoiler: :class:`bool` + Whether this item should be flagged as a spoiler. + """ + + __slots__ = ( + '_media', + 'description', + 'spoiler', + '_state', + ) -@overload -def _component_factory(data: ComponentPayload) -> Optional[Union[ActionRow, ActionRowChildComponentType]]: - ... + def __init__( + self, + media: Union[str, UnfurledMediaItem], + *, + description: Optional[str] = None, + spoiler: bool = False, + ) -> None: + self._media: UnfurledMediaItem = UnfurledMediaItem(media) if isinstance(media, str) else media + self.description: Optional[str] = description + self.spoiler: bool = spoiler + self._state: Optional[ConnectionState] = None + + def __repr__(self) -> str: + return f'' + + @property + def media(self) -> UnfurledMediaItem: + """:class:`UnfurledMediaItem`: This item's media data.""" + return self._media + + @media.setter + def media(self, value: Union[str, UnfurledMediaItem]) -> None: + if isinstance(value, str): + self._media = UnfurledMediaItem(value) + elif isinstance(value, UnfurledMediaItem): + self._media = value + else: + raise TypeError(f'Expected a str or UnfurledMediaItem, not {value.__class__.__name__}') + + @classmethod + def _from_data(cls, data: MediaGalleryItemPayload, state: Optional[ConnectionState]) -> MediaGalleryItem: + media = data['media'] + self = cls( + media=UnfurledMediaItem._from_data(media, state), + description=data.get('description'), + spoiler=data.get('spoiler', False), + ) + self._state = state + return self + + @classmethod + def _from_gallery( + cls, + items: List[MediaGalleryItemPayload], + state: Optional[ConnectionState], + ) -> List[MediaGalleryItem]: + return [cls._from_data(item, state) for item in items] + + def to_dict(self) -> MediaGalleryItemPayload: + payload: MediaGalleryItemPayload = { + 'media': self.media.to_dict(), # type: ignore + 'spoiler': self.spoiler, + } + + if self.description: + payload['description'] = self.description + + return payload + + +class MediaGalleryComponent(Component): + """Represents a Media Gallery component from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type for creating a media gallery is + :class:`discord.ui.MediaGallery` not this one. + + .. versionadded:: 2.6 + + Attributes + ---------- + items: List[:class:`MediaGalleryItem`] + The items this gallery has. + id: Optional[:class:`int`] + The ID of this component. + """ + + __slots__ = ('items', 'id') + + __repr_info__ = __slots__ + + def __init__(self, data: MediaGalleryComponentPayload, state: Optional[ConnectionState]) -> None: + self.items: List[MediaGalleryItem] = MediaGalleryItem._from_gallery(data['items'], state) + self.id: Optional[int] = data.get('id') + + @property + def type(self) -> Literal[ComponentType.media_gallery]: + return ComponentType.media_gallery + + def to_dict(self) -> MediaGalleryComponentPayload: + payload: MediaGalleryComponentPayload = { + 'type': self.type.value, + 'items': [item.to_dict() for item in self.items], + } + if self.id is not None: + payload['id'] = self.id + return payload + + +class FileComponent(Component): + """Represents a File component from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type for create a file component is + :class:`discord.ui.File` not this one. + + .. versionadded:: 2.6 + + Attributes + ---------- + media: :class:`UnfurledMediaItem` + The unfurled attachment contents of the file. + spoiler: :class:`bool` + Whether this file is flagged as a spoiler. + id: Optional[:class:`int`] + The ID of this component. + name: Optional[:class:`str`] + The displayed file name, only available when received from the API. + size: Optional[:class:`int`] + The file size in MiB, only available when received from the API. + """ + + __slots__ = ( + 'media', + 'spoiler', + 'id', + 'name', + 'size', + ) + + __repr_info__ = __slots__ + + def __init__(self, data: FileComponentPayload, state: Optional[ConnectionState]) -> None: + self.media: UnfurledMediaItem = UnfurledMediaItem._from_data(data['file'], state) + self.spoiler: bool = data.get('spoiler', False) + self.id: Optional[int] = data.get('id') + self.name: Optional[str] = data.get('name') + self.size: Optional[int] = data.get('size') + + @property + def type(self) -> Literal[ComponentType.file]: + return ComponentType.file + + def to_dict(self) -> FileComponentPayload: + payload: FileComponentPayload = { + 'type': self.type.value, + 'file': self.media.to_dict(), # type: ignore + 'spoiler': self.spoiler, + } + if self.id is not None: + payload['id'] = self.id + return payload + + +class SeparatorComponent(Component): + """Represents a Separator from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type for creating a separator is + :class:`discord.ui.Separator` not this one. + + .. versionadded:: 2.6 + + Attributes + ---------- + spacing: :class:`SeparatorSpacing` + The spacing size of the separator. + visible: :class:`bool` + Whether this separator is visible and shows a divider. + id: Optional[:class:`int`] + The ID of this component. + """ + + __slots__ = ( + 'spacing', + 'visible', + 'id', + ) + + __repr_info__ = __slots__ + + def __init__( + self, + data: SeparatorComponentPayload, + ) -> None: + self.spacing: SeparatorSpacing = try_enum(SeparatorSpacing, data.get('spacing', 1)) + self.visible: bool = data.get('divider', True) + self.id: Optional[int] = data.get('id') + + @property + def type(self) -> Literal[ComponentType.separator]: + return ComponentType.separator + + def to_dict(self) -> SeparatorComponentPayload: + payload: SeparatorComponentPayload = { + 'type': self.type.value, + 'divider': self.visible, + 'spacing': self.spacing.value, + } + if self.id is not None: + payload['id'] = self.id + return payload + + +class Container(Component): + """Represents a Container from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type for creating a container is + :class:`discord.ui.Container` not this one. + + .. versionadded:: 2.6 + + Attributes + ---------- + children: :class:`Component` + This container's children. + spoiler: :class:`bool` + Whether this container is flagged as a spoiler. + id: Optional[:class:`int`] + The ID of this component. + """ + + __slots__ = ( + 'children', + 'id', + 'spoiler', + '_colour', + ) + + __repr_info__ = ( + 'children', + 'id', + 'spoiler', + 'accent_colour', + ) + + def __init__(self, data: ContainerComponentPayload, state: Optional[ConnectionState]) -> None: + self.children: List[Component] = [] + self.id: Optional[int] = data.get('id') + + for child in data['components']: + comp = _component_factory(child, state) + + if comp: + self.children.append(comp) + + self.spoiler: bool = data.get('spoiler', False) + + colour = data.get('accent_color') + self._colour: Optional[Colour] = None + if colour is not None: + self._colour = Colour(colour) + + @property + def accent_colour(self) -> Optional[Colour]: + """Optional[:class:`Colour`]: The container's accent colour.""" + return self._colour + + accent_color = accent_colour + + @property + def type(self) -> Literal[ComponentType.container]: + return ComponentType.container + + def to_dict(self) -> ContainerComponentPayload: + payload: ContainerComponentPayload = { + 'type': self.type.value, + 'spoiler': self.spoiler, + 'components': [c.to_dict() for c in self.children], # pyright: ignore[reportAssignmentType] + } + if self.id is not None: + payload['id'] = self.id + if self._colour: + payload['accent_color'] = self._colour.value + return payload -def _component_factory(data: ComponentPayload) -> Optional[Union[ActionRow, ActionRowChildComponentType]]: +def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] = None) -> Optional[Component]: if data['type'] == 1: return ActionRow(data) elif data['type'] == 2: @@ -663,4 +1317,18 @@ def _component_factory(data: ComponentPayload) -> Optional[Union[ActionRow, Acti elif data['type'] == 4: return TextInput(data) elif data['type'] in (3, 5, 6, 7, 8): - return SelectMenu(data) + return SelectMenu(data) # type: ignore + elif data['type'] == 9: + return SectionComponent(data, state) + elif data['type'] == 10: + return TextDisplay(data) + elif data['type'] == 11: + return ThumbnailComponent(data, state) + elif data['type'] == 12: + return MediaGalleryComponent(data, state) + elif data['type'] == 13: + return FileComponent(data, state) + elif data['type'] == 14: + return SeparatorComponent(data) + elif data['type'] == 17: + return Container(data, state) diff --git a/discord/enums.py b/discord/enums.py index c5c944ca00f5..b25c221a823a 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -81,6 +81,8 @@ 'StatusDisplayType', 'OnboardingPromptType', 'OnboardingMode', + 'SeparatorSpacing', + 'MediaItemLoadingState', ) @@ -668,6 +670,13 @@ class ComponentType(Enum): role_select = 6 mentionable_select = 7 channel_select = 8 + section = 9 + text_display = 10 + thumbnail = 11 + media_gallery = 12 + file = 13 + separator = 14 + container = 17 def __int__(self) -> int: return self.value @@ -953,6 +962,18 @@ class OnboardingMode(Enum): advanced = 1 +class SeparatorSpacing(Enum): + small = 1 + large = 2 + + +class MediaItemLoadingState(Enum): + unknown = 0 + loading = 1 + loaded = 2 + not_found = 3 + + def create_unknown_value(cls: Type[E], val: Any) -> E: value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below name = f'unknown_{val}' diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index b5b96c15f9a6..4171a82c65ba 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -48,7 +48,7 @@ from discord.mentions import AllowedMentions from discord.sticker import GuildSticker, StickerItem from discord.message import MessageReference, PartialMessage - from discord.ui import View + from discord.ui.view import BaseView, View, LayoutView from discord.types.interactions import ApplicationCommandInteractionData from discord.poll import Poll @@ -628,6 +628,40 @@ async def send_help(self, *args: Any) -> Any: except CommandError as e: await cmd.on_help_command_error(self, e) + @overload + async def reply( + self, + *, + file: File = ..., + delete_after: float = ..., + nonce: Union[str, int] = ..., + allowed_mentions: AllowedMentions = ..., + reference: Union[Message, MessageReference, PartialMessage] = ..., + mention_author: bool = ..., + view: LayoutView, + suppress_embeds: bool = ..., + ephemeral: bool = ..., + silent: bool = ..., + ) -> Message: + ... + + @overload + async def reply( + self, + *, + files: Sequence[File] = ..., + delete_after: float = ..., + nonce: Union[str, int] = ..., + allowed_mentions: AllowedMentions = ..., + reference: Union[Message, MessageReference, PartialMessage] = ..., + mention_author: bool = ..., + view: LayoutView, + suppress_embeds: bool = ..., + ephemeral: bool = ..., + silent: bool = ..., + ) -> Message: + ... + @overload async def reply( self, @@ -817,6 +851,40 @@ async def defer(self, *, ephemeral: bool = False) -> None: if self.interaction: await self.interaction.response.defer(ephemeral=ephemeral) + @overload + async def send( + self, + *, + file: File = ..., + delete_after: float = ..., + nonce: Union[str, int] = ..., + allowed_mentions: AllowedMentions = ..., + reference: Union[Message, MessageReference, PartialMessage] = ..., + mention_author: bool = ..., + view: LayoutView, + suppress_embeds: bool = ..., + ephemeral: bool = ..., + silent: bool = ..., + ) -> Message: + ... + + @overload + async def send( + self, + *, + files: Sequence[File] = ..., + delete_after: float = ..., + nonce: Union[str, int] = ..., + allowed_mentions: AllowedMentions = ..., + reference: Union[Message, MessageReference, PartialMessage] = ..., + mention_author: bool = ..., + view: LayoutView, + suppress_embeds: bool = ..., + ephemeral: bool = ..., + silent: bool = ..., + ) -> Message: + ... + @overload async def send( self, @@ -920,7 +988,7 @@ async def send( allowed_mentions: Optional[AllowedMentions] = None, reference: Optional[Union[Message, MessageReference, PartialMessage]] = None, mention_author: Optional[bool] = None, - view: Optional[View] = None, + view: Optional[BaseView] = None, suppress_embeds: bool = False, ephemeral: bool = False, silent: bool = False, @@ -986,7 +1054,7 @@ async def send( This is ignored for interaction based contexts. .. versionadded:: 1.6 - view: :class:`discord.ui.View` + view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`] A Discord UI View to add to the message. .. versionadded:: 2.0 diff --git a/discord/flags.py b/discord/flags.py index 8a7a66954aae..245ff6dc13fd 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -500,6 +500,16 @@ def forwarded(self): """ return 16384 + @flag_value + def components_v2(self): + """:class:`bool`: Returns ``True`` if the message has Discord's v2 components. + + Does not allow sending any ``content``, ``embed``, ``embeds``, ``stickers``, or ``poll``. + + .. versionadded:: 2.6 + """ + return 32768 + @fill_with_flags() class PublicUserFlags(BaseFlags): diff --git a/discord/http.py b/discord/http.py index 41927c6835c1..800e4cab1a14 100644 --- a/discord/http.py +++ b/discord/http.py @@ -57,16 +57,16 @@ from .mentions import AllowedMentions from . import __version__, utils from .utils import MISSING +from .flags import MessageFlags _log = logging.getLogger(__name__) if TYPE_CHECKING: from typing_extensions import Self - from .ui.view import View + from .ui.view import BaseView from .embeds import Embed from .message import Attachment - from .flags import MessageFlags from .poll import Poll from .types import ( @@ -151,7 +151,7 @@ def handle_message_parameters( embed: Optional[Embed] = MISSING, embeds: Sequence[Embed] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING, - view: Optional[View] = MISSING, + view: Optional[BaseView] = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING, message_reference: Optional[message.MessageReference] = MISSING, stickers: Optional[SnowflakeList] = MISSING, @@ -194,6 +194,12 @@ def handle_message_parameters( if view is not MISSING: if view is not None: payload['components'] = view.to_components() + + if view.has_components_v2(): + if flags is not MISSING: + flags.components_v2 = True + else: + flags = MessageFlags(components_v2=True) else: payload['components'] = [] diff --git a/discord/interactions.py b/discord/interactions.py index 82b35e39228e..3cbc4107d3f6 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -27,7 +27,7 @@ from __future__ import annotations import logging -from typing import Any, Dict, Optional, Generic, TYPE_CHECKING, Sequence, Tuple, Union, List +from typing import Any, Dict, Optional, Generic, TYPE_CHECKING, Sequence, Tuple, Union, List, overload import asyncio import datetime @@ -76,7 +76,7 @@ from .mentions import AllowedMentions from aiohttp import ClientSession from .embeds import Embed - from .ui.view import View + from .ui.view import BaseView, View, LayoutView from .app_commands.models import Choice, ChoiceT from .ui.modal import Modal from .channel import VoiceChannel, StageChannel, TextChannel, ForumChannel, CategoryChannel, DMChannel, GroupChannel @@ -482,7 +482,7 @@ async def edit_original_response( embeds: Sequence[Embed] = MISSING, embed: Optional[Embed] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING, - view: Optional[View] = MISSING, + view: Optional[Union[View, LayoutView]] = MISSING, allowed_mentions: Optional[AllowedMentions] = None, poll: Poll = MISSING, ) -> InteractionMessage: @@ -516,9 +516,15 @@ async def edit_original_response( allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] + view: Optional[Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`]] The updated view to update this message with. If ``None`` is passed then the view is removed. + + .. note:: + + If you want to update the message to have a :class:`~discord.ui.LayoutView`, you must + explicitly set the ``content``, ``embed``, ``embeds``, and ``attachments`` parameters to + ``None`` if the previous message had any. poll: :class:`Poll` The poll to create when editing the message. @@ -574,7 +580,7 @@ async def edit_original_response( # The message channel types should always match state = _InteractionMessageState(self, self._state) message = InteractionMessage(state=state, channel=self.channel, data=data) # type: ignore - if view and not view.is_finished(): + if view and not view.is_finished() and view.is_dispatchable(): self._state.store_view(view, message.id, interaction_id=self.id) return message @@ -898,6 +904,22 @@ async def pong(self) -> None: ) self._response_type = InteractionResponseType.pong + @overload + async def send_message( + self, + *, + file: File = MISSING, + files: Sequence[File] = MISSING, + view: LayoutView, + ephemeral: bool = False, + allowed_mentions: AllowedMentions = MISSING, + suppress_embeds: bool = False, + silent: bool = False, + delete_after: Optional[float] = None, + ) -> InteractionCallbackResponse[ClientT]: + ... + + @overload async def send_message( self, content: Optional[Any] = None, @@ -914,6 +936,25 @@ async def send_message( silent: bool = False, delete_after: Optional[float] = None, poll: Poll = MISSING, + ) -> InteractionCallbackResponse[ClientT]: + ... + + async def send_message( + self, + content: Optional[Any] = None, + *, + embed: Embed = MISSING, + embeds: Sequence[Embed] = MISSING, + file: File = MISSING, + files: Sequence[File] = MISSING, + view: BaseView = MISSING, + tts: bool = False, + ephemeral: bool = False, + allowed_mentions: AllowedMentions = MISSING, + suppress_embeds: bool = False, + silent: bool = False, + delete_after: Optional[float] = None, + poll: Poll = MISSING, ) -> InteractionCallbackResponse[ClientT]: """|coro| @@ -938,7 +979,7 @@ async def send_message( A list of files to upload. Must be a maximum of 10. tts: :class:`bool` Indicates if the message should be sent using text-to-speech. - view: :class:`discord.ui.View` + view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`] The view to send with the message. ephemeral: :class:`bool` Indicates if the message should only be visible to the user who started the interaction. @@ -1055,7 +1096,7 @@ async def edit_message( embed: Optional[Embed] = MISSING, embeds: Sequence[Embed] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING, - view: Optional[View] = MISSING, + view: Optional[Union[View, LayoutView]] = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING, delete_after: Optional[float] = None, suppress_embeds: bool = MISSING, @@ -1085,9 +1126,15 @@ async def edit_message( New files will always appear after current attachments. - view: Optional[:class:`~discord.ui.View`] + view: Optional[Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`]] The updated view to update this message with. If ``None`` is passed then the view is removed. + + .. note:: + + To update the message to add a :class:`~discord.ui.LayoutView`, you + must explicitly set the ``content``, ``embed``, ``embeds``, and + ``attachments`` parameters to either ``None`` or an empty array, as appropriate. allowed_mentions: Optional[:class:`~discord.AllowedMentions`] Controls the mentions being processed in this message. See :meth:`.Message.edit` for more information. @@ -1169,7 +1216,7 @@ async def edit_message( params=params, ) - if view and not view.is_finished(): + if view and not view.is_finished() and view.is_dispatchable(): state.store_view(view, message_id, interaction_id=original_interaction_id) self._response_type = InteractionResponseType.message_update @@ -1382,6 +1429,18 @@ class InteractionMessage(Message): __slots__ = () _state: _InteractionMessageState + @overload + async def edit( + self, + *, + attachments: Sequence[Union[Attachment, File]] = MISSING, + view: LayoutView, + allowed_mentions: Optional[AllowedMentions] = None, + delete_after: Optional[float] = None, + ) -> InteractionMessage: + ... + + @overload async def edit( self, *, @@ -1393,6 +1452,20 @@ async def edit( allowed_mentions: Optional[AllowedMentions] = None, delete_after: Optional[float] = None, poll: Poll = MISSING, + ) -> InteractionMessage: + ... + + async def edit( + self, + *, + content: Optional[str] = MISSING, + embeds: Sequence[Embed] = MISSING, + embed: Optional[Embed] = MISSING, + attachments: Sequence[Union[Attachment, File]] = MISSING, + view: Optional[Union[View, LayoutView]] = MISSING, + allowed_mentions: Optional[AllowedMentions] = None, + delete_after: Optional[float] = None, + poll: Poll = MISSING, ) -> InteractionMessage: """|coro| @@ -1418,9 +1491,15 @@ async def edit( allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] + view: Optional[Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`]] The updated view to update this message with. If ``None`` is passed then the view is removed. + + .. note:: + + If you want to update the message to have a :class:`~discord.ui.LayoutView`, you must + explicitly set the ``content``, ``embed``, ``embeds``, and ``attachments`` parameters to + ``None`` if the previous message had any. delete_after: Optional[:class:`float`] If provided, the number of seconds to wait in the background before deleting the message we just sent. If the deletion fails, diff --git a/discord/message.py b/discord/message.py index d6a26c7d0d6f..039ac1ba745b 100644 --- a/discord/message.py +++ b/discord/message.py @@ -96,15 +96,14 @@ from .types.gateway import MessageReactionRemoveEvent, MessageUpdateEvent from .abc import Snowflake from .abc import GuildChannel, MessageableChannel - from .components import ActionRow, ActionRowChildComponentType + from .components import MessageComponentType from .state import ConnectionState from .mentions import AllowedMentions from .user import User from .role import Role - from .ui.view import View + from .ui.view import View, LayoutView EmojiInputType = Union[Emoji, PartialEmoji, str] - MessageComponentType = Union[ActionRow, ActionRowChildComponentType] __all__ = ( @@ -489,7 +488,7 @@ class MessageSnapshot: Extra features of the the message snapshot. stickers: List[:class:`StickerItem`] A list of sticker items given to the message. - components: List[Union[:class:`ActionRow`, :class:`Button`, :class:`SelectMenu`]] + components: List[Union[:class:`ActionRow`, :class:`Button`, :class:`SelectMenu`, :class:`Container`, :class:`SectionComponent`, :class:`TextDisplay`, :class:`MediaGalleryComponent`, :class:`FileComponent`, :class:`SeparatorComponent`, :class:`ThumbnailComponent`]] A list of components in the message. """ @@ -533,7 +532,7 @@ def __init__(self, state: ConnectionState, data: MessageSnapshotPayload): self.components: List[MessageComponentType] = [] for component_data in data.get('components', []): - component = _component_factory(component_data) + component = _component_factory(component_data, state) # type: ignore if component is not None: self.components.append(component) @@ -1306,32 +1305,6 @@ async def delete(delay: float): else: await self._state.http.delete_message(self.channel.id, self.id) - @overload - async def edit( - self, - *, - content: Optional[str] = ..., - embed: Optional[Embed] = ..., - attachments: Sequence[Union[Attachment, File]] = ..., - delete_after: Optional[float] = ..., - allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[View] = ..., - ) -> Message: - ... - - @overload - async def edit( - self, - *, - content: Optional[str] = ..., - embeds: Sequence[Embed] = ..., - attachments: Sequence[Union[Attachment, File]] = ..., - delete_after: Optional[float] = ..., - allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[View] = ..., - ) -> Message: - ... - async def edit( self, *, @@ -1341,7 +1314,7 @@ async def edit( attachments: Sequence[Union[Attachment, File]] = MISSING, delete_after: Optional[float] = None, allowed_mentions: Optional[AllowedMentions] = MISSING, - view: Optional[View] = MISSING, + view: Optional[Union[View, LayoutView]] = MISSING, ) -> Message: """|coro| @@ -1391,10 +1364,16 @@ async def edit( are used instead. .. versionadded:: 1.4 - view: Optional[:class:`~discord.ui.View`] + view: Optional[Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`]] The updated view to update this message with. If ``None`` is passed then the view is removed. + .. note:: + + If you want to update the message to have a :class:`~discord.ui.LayoutView`, you must + explicitly set the ``content``, ``embed``, ``embeds``, and ``attachments`` parameters to + ``None`` if the previous message had any. + Raises ------- HTTPException @@ -1433,8 +1412,8 @@ async def edit( data = await self._state.http.edit_message(self.channel.id, self.id, params=params) message = Message(state=self._state, channel=self.channel, data=data) - if view and not view.is_finished(): - interaction: Optional[MessageInteraction] = getattr(self, 'interaction', None) + if view and not view.is_finished() and view.is_dispatchable(): + interaction: Optional[MessageInteractionMetadata] = getattr(self, 'interaction_metadata', None) if interaction is not None: self._state.store_view(view, self.id, interaction_id=interaction.id) else: @@ -1756,6 +1735,38 @@ async def fetch_thread(self) -> Thread: return await self.guild.fetch_channel(self.id) # type: ignore # Can only be Thread in this case + @overload + async def reply( + self, + *, + file: File = ..., + view: LayoutView, + delete_after: float = ..., + nonce: Union[str, int] = ..., + allowed_mentions: AllowedMentions = ..., + reference: Union[Message, MessageReference, PartialMessage] = ..., + mention_author: bool = ..., + suppress_embeds: bool = ..., + silent: bool = ..., + ) -> Message: + ... + + @overload + async def reply( + self, + *, + files: Sequence[File] = ..., + view: LayoutView, + delete_after: float = ..., + nonce: Union[str, int] = ..., + allowed_mentions: AllowedMentions = ..., + reference: Union[Message, MessageReference, PartialMessage] = ..., + mention_author: bool = ..., + suppress_embeds: bool = ..., + silent: bool = ..., + ) -> Message: + ... + @overload async def reply( self, @@ -2846,34 +2857,6 @@ def system_content(self) -> str: # Fallback for unknown message types return '' - @overload - async def edit( - self, - *, - content: Optional[str] = ..., - embed: Optional[Embed] = ..., - attachments: Sequence[Union[Attachment, File]] = ..., - suppress: bool = ..., - delete_after: Optional[float] = ..., - allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[View] = ..., - ) -> Message: - ... - - @overload - async def edit( - self, - *, - content: Optional[str] = ..., - embeds: Sequence[Embed] = ..., - attachments: Sequence[Union[Attachment, File]] = ..., - suppress: bool = ..., - delete_after: Optional[float] = ..., - allowed_mentions: Optional[AllowedMentions] = ..., - view: Optional[View] = ..., - ) -> Message: - ... - async def edit( self, *, @@ -2884,7 +2867,7 @@ async def edit( suppress: bool = False, delete_after: Optional[float] = None, allowed_mentions: Optional[AllowedMentions] = MISSING, - view: Optional[View] = MISSING, + view: Optional[Union[View, LayoutView]] = MISSING, ) -> Message: """|coro| @@ -2942,10 +2925,16 @@ async def edit( are used instead. .. versionadded:: 1.4 - view: Optional[:class:`~discord.ui.View`] + view: Optional[Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`]] The updated view to update this message with. If ``None`` is passed then the view is removed. + .. note:: + + If you want to update the message to have a :class:`~discord.ui.LayoutView`, you must + explicitly set the ``content``, ``embed``, ``embeds``, and ``attachments`` parameters to + ``None`` if the previous message had any. + Raises ------- HTTPException @@ -2991,7 +2980,7 @@ async def edit( data = await self._state.http.edit_message(self.channel.id, self.id, params=params) message = Message(state=self._state, channel=self.channel, data=data) - if view and not view.is_finished(): + if view and not view.is_finished() and view.is_dispatchable(): self._state.store_view(view, self.id) if delete_after is not None: diff --git a/discord/state.py b/discord/state.py index 12e227dc553b..90f482856a9e 100644 --- a/discord/state.py +++ b/discord/state.py @@ -71,7 +71,7 @@ from .invite import Invite from .integrations import _integration_factory from .interactions import Interaction -from .ui.view import ViewStore, View +from .ui.view import ViewStore, BaseView from .scheduled_event import ScheduledEvent from .stage_instance import StageInstance from .threads import Thread, ThreadMember @@ -412,12 +412,12 @@ def store_sticker(self, guild: Guild, data: GuildStickerPayload) -> GuildSticker self._stickers[sticker_id] = sticker = GuildSticker(state=self, data=data) return sticker - def store_view(self, view: View, message_id: Optional[int] = None, interaction_id: Optional[int] = None) -> None: + def store_view(self, view: BaseView, message_id: Optional[int] = None, interaction_id: Optional[int] = None) -> None: if interaction_id is not None: self._view_store.remove_interaction_mapping(interaction_id) self._view_store.add_view(view, message_id) - def prevent_view_updates_for(self, message_id: int) -> Optional[View]: + def prevent_view_updates_for(self, message_id: int) -> Optional[BaseView]: return self._view_store.remove_message_tracking(message_id) def store_dynamic_items(self, *items: Type[DynamicItem[Item[Any]]]) -> None: @@ -427,7 +427,7 @@ def remove_dynamic_items(self, *items: Type[DynamicItem[Item[Any]]]) -> None: self._view_store.remove_dynamic_items(*items) @property - def persistent_views(self) -> Sequence[View]: + def persistent_views(self) -> Sequence[BaseView]: return self._view_store.persistent_views @property diff --git a/discord/types/components.py b/discord/types/components.py index 3b1295c1393c..189122baef32 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -24,24 +24,31 @@ from __future__ import annotations -from typing import List, Literal, TypedDict, Union +from typing import List, Literal, Optional, TypedDict, Union from typing_extensions import NotRequired from .emoji import PartialEmoji from .channel import ChannelType -ComponentType = Literal[1, 2, 3, 4] +ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] TextStyle = Literal[1, 2] DefaultValueType = Literal['user', 'role', 'channel'] +SeparatorSpacing = Literal[1, 2] +MediaItemLoadingState = Literal[0, 1, 2, 3] -class ActionRow(TypedDict): +class ComponentBase(TypedDict): + id: NotRequired[int] + type: int + + +class ActionRow(ComponentBase): type: Literal[1] components: List[ActionRowChildComponent] -class ButtonComponent(TypedDict): +class ButtonComponent(ComponentBase): type: Literal[2] style: ButtonStyle custom_id: NotRequired[str] @@ -60,7 +67,7 @@ class SelectOption(TypedDict): emoji: NotRequired[PartialEmoji] -class SelectComponent(TypedDict): +class SelectComponent(ComponentBase): custom_id: str placeholder: NotRequired[str] min_values: NotRequired[int] @@ -99,7 +106,7 @@ class ChannelSelectComponent(SelectComponent): default_values: NotRequired[List[SelectDefaultValues]] -class TextInput(TypedDict): +class TextInput(ComponentBase): type: Literal[4] custom_id: str style: TextStyle @@ -118,5 +125,78 @@ class SelectMenu(SelectComponent): default_values: NotRequired[List[SelectDefaultValues]] +class SectionComponent(ComponentBase): + type: Literal[9] + components: List[Union[TextComponent, ButtonComponent]] + accessory: Component + + +class TextComponent(ComponentBase): + type: Literal[10] + content: str + + +class UnfurledMediaItem(TypedDict): + url: str + proxy_url: str + height: NotRequired[Optional[int]] + width: NotRequired[Optional[int]] + content_type: NotRequired[str] + placeholder: str + loading_state: MediaItemLoadingState + attachment_id: NotRequired[int] + flags: NotRequired[int] + + +class ThumbnailComponent(ComponentBase): + type: Literal[11] + media: UnfurledMediaItem + description: NotRequired[Optional[str]] + spoiler: NotRequired[bool] + + +class MediaGalleryItem(TypedDict): + media: UnfurledMediaItem + description: NotRequired[str] + spoiler: NotRequired[bool] + + +class MediaGalleryComponent(ComponentBase): + type: Literal[12] + items: List[MediaGalleryItem] + + +class FileComponent(ComponentBase): + type: Literal[13] + file: UnfurledMediaItem + spoiler: NotRequired[bool] + name: NotRequired[str] + size: NotRequired[int] + + +class SeparatorComponent(ComponentBase): + type: Literal[14] + divider: NotRequired[bool] + spacing: NotRequired[SeparatorSpacing] + + +class ContainerComponent(ComponentBase): + type: Literal[17] + accent_color: NotRequired[int] + spoiler: NotRequired[bool] + components: List[ContainerChildComponent] + + ActionRowChildComponent = Union[ButtonComponent, SelectMenu, TextInput] -Component = Union[ActionRow, ActionRowChildComponent] +ContainerChildComponent = Union[ + ActionRow, + TextComponent, + MediaGalleryComponent, + FileComponent, + SectionComponent, + SectionComponent, + ContainerComponent, + SeparatorComponent, + ThumbnailComponent, +] +Component = Union[ActionRowChildComponent, ContainerChildComponent] diff --git a/discord/types/message.py b/discord/types/message.py index ae38db46f8c0..6c260d44dbdf 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -33,7 +33,7 @@ from .emoji import PartialEmoji from .embed import Embed from .channel import ChannelType -from .components import Component +from .components import ComponentBase from .interactions import MessageInteraction, MessageInteractionMetadata from .sticker import StickerItem from .threads import Thread @@ -189,7 +189,7 @@ class MessageSnapshot(TypedDict): mentions: List[UserWithMember] mention_roles: SnowflakeList sticker_items: NotRequired[List[StickerItem]] - components: NotRequired[List[Component]] + components: NotRequired[List[ComponentBase]] class Message(PartialMessage): @@ -221,7 +221,7 @@ class Message(PartialMessage): referenced_message: NotRequired[Optional[Message]] interaction: NotRequired[MessageInteraction] # deprecated, use interaction_metadata interaction_metadata: NotRequired[MessageInteractionMetadata] - components: NotRequired[List[Component]] + components: NotRequired[List[ComponentBase]] position: NotRequired[int] role_subscription_data: NotRequired[RoleSubscriptionData] thread: NotRequired[Thread] diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index c5a51777ce3e..4d613f14faf0 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -16,3 +16,11 @@ from .select import * from .text_input import * from .dynamic import * +from .container import * +from .file import * +from .media_gallery import * +from .section import * +from .separator import * +from .text_display import * +from .thumbnail import * +from .action_row import * diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py new file mode 100644 index 000000000000..31ab6d17dd17 --- /dev/null +++ b/discord/ui/action_row.py @@ -0,0 +1,585 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +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. +""" +from __future__ import annotations + +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ClassVar, + Coroutine, + Dict, + Generator, + List, + Literal, + Optional, + Sequence, + Type, + TypeVar, + Union, + overload, +) + +from .item import I, Item +from .button import Button, button as _button +from .select import select as _select, Select, UserSelect, RoleSelect, ChannelSelect, MentionableSelect +from ..components import ActionRow as ActionRowComponent +from ..enums import ButtonStyle, ComponentType, ChannelType +from ..partial_emoji import PartialEmoji +from ..utils import MISSING, get as _utils_get + +if TYPE_CHECKING: + from typing_extensions import Self + + from .view import LayoutView + from .select import ( + BaseSelectT, + ValidDefaultValues, + MentionableSelectT, + ChannelSelectT, + RoleSelectT, + UserSelectT, + SelectT, + ) + from ..emoji import Emoji + from ..components import SelectOption + from ..interactions import Interaction + + ItemCallbackType = Callable[['S', Interaction[Any], I], Coroutine[Any, Any, Any]] + SelectCallbackDecorator = Callable[[ItemCallbackType['S', BaseSelectT]], BaseSelectT] + +S = TypeVar('S', bound='ActionRow', covariant=True) +V = TypeVar('V', bound='LayoutView', covariant=True) + +__all__ = ('ActionRow',) + + +class _ActionRowCallback: + __slots__ = ('row', 'callback', 'item') + + def __init__(self, callback: ItemCallbackType[S, Any], row: ActionRow, item: Item[Any]) -> None: + self.callback: ItemCallbackType[Any, Any] = callback + self.row: ActionRow = row + self.item: Item[Any] = item + + def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: + return self.callback(self.row, interaction, self.item) + + +class ActionRow(Item[V]): + r"""Represents a UI action row. + + This is a top-level layout component that can only be used on :class:`LayoutView` + and can contain :class:`Button`\s and :class:`Select`\s in it. + + Action rows can only have 5 children. This can be inherited. + + .. versionadded:: 2.6 + + Examples + -------- + + .. code-block:: python3 + + import discord + from discord import ui + + # you can subclass it and add components with the decorators + class MyActionRow(ui.ActionRow): + @ui.button(label='Click Me!') + async def click_me(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_message('You clicked me!') + + # or use it directly on LayoutView + class MyView(ui.LayoutView): + row = ui.ActionRow() + # or you can use your subclass: + # row = MyActionRow() + + # you can add items with row.button and row.select + @row.button(label='A button!') + async def row_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_message('You clicked a button!') + + Parameters + ---------- + \*children: :class:`Item` + The initial children of this action row. + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. + """ + + __action_row_children_items__: ClassVar[List[ItemCallbackType[Self, Any]]] = [] + __discord_ui_action_row__: ClassVar[bool] = True + __item_repr_attributes__ = ('id',) + + def __init__( + self, + *children: Item[V], + id: Optional[int] = None, + ) -> None: + super().__init__() + self._children: List[Item[V]] = self._init_children() + self._children.extend(children) + self._weight: int = sum(i.width for i in self._children) + + if self._weight > 5: + raise ValueError('maximum number of children exceeded') + + self.id = id + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + children: Dict[str, ItemCallbackType[Self, Any]] = {} + for base in reversed(cls.__mro__): + for name, member in base.__dict__.items(): + if hasattr(member, '__discord_ui_model_type__'): + children[name] = member + + if len(children) > 5: + raise TypeError('ActionRow cannot have more than 5 children') + + cls.__action_row_children_items__ = list(children.values()) + + def __repr__(self) -> str: + return f'<{self.__class__.__name__} children={len(self._children)}>' + + def _init_children(self) -> List[Item[Any]]: + children = [] + + for func in self.__action_row_children_items__: + item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) + item.callback = _ActionRowCallback(func, self, item) # type: ignore + item._parent = getattr(func, '__discord_ui_parent__', self) + setattr(self, func.__name__, item) + children.append(item) + return children + + def _update_view(self, view) -> None: + self._view = view + for child in self._children: + child._view = view + + def _has_children(self): + return True + + def _is_v2(self) -> bool: + # although it is not really a v2 component the only usecase here is for + # LayoutView which basically represents the top-level payload of components + # and ActionRow is only allowed there anyways. + # If the user tries to add any V2 component to a View instead of LayoutView + # it should error anyways. + return True + + @property + def width(self): + return 5 + + @property + def type(self) -> Literal[ComponentType.action_row]: + return ComponentType.action_row + + @property + def children(self) -> List[Item[V]]: + """List[:class:`Item`]: The list of children attached to this action row.""" + return self._children.copy() + + def walk_children(self) -> Generator[Item[V], Any, None]: + """An iterator that recursively walks through all the children of this action row + and its children, if applicable. + + Yields + ------ + :class:`Item` + An item in the action row. + """ + + for child in self.children: + yield child + + def add_item(self, item: Item[Any]) -> Self: + """Adds an item to this action row. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + item: :class:`Item` + The item to add to the action row. + + Raises + ------ + TypeError + An :class:`Item` was not passed. + ValueError + Maximum number of children has been exceeded (5). + """ + + if (self._weight + item.width) > 5: + raise ValueError('maximum number of children exceeded') + + if len(self._children) >= 5: + raise ValueError('maximum number of children exceeded') + + if not isinstance(item, Item): + raise TypeError(f'expected Item not {item.__class__.__name__}') + + item._update_view(self.view) + item._parent = self + self._weight += 1 + self._children.append(item) + + if self._view: + self._view._total_children += 1 + + return self + + def remove_item(self, item: Item[Any]) -> Self: + """Removes an item from the action row. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + item: :class:`Item` + The item to remove from the action row. + """ + + try: + self._children.remove(item) + except ValueError: + pass + else: + if self._view and self._view._is_layout(): + self._view._total_children -= 1 + self._weight -= 1 + + return self + + def find_item(self, id: int, /) -> Optional[Item[V]]: + """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if + not found. + + .. warning:: + + This is **not the same** as ``custom_id``. + + Parameters + ---------- + id: :class:`int` + The ID of the component. + + Returns + ------- + Optional[:class:`Item`] + The item found, or ``None``. + """ + return _utils_get(self.walk_children(), id=id) + + def clear_items(self) -> Self: + """Removes all items from the action row. + + This function returns the class instance to allow for fluent-style + chaining. + """ + if self._view and self._view._is_layout(): + self._view._total_children -= len(self._children) + self._children.clear() + self._weight = 0 + return self + + def to_component_dict(self) -> Dict[str, Any]: + components = [] + for component in self.children: + components.append(component.to_component_dict()) + + base = { + 'type': self.type.value, + 'components': components, + } + if self.id is not None: + base['id'] = self.id + return base + + def button( + self, + *, + label: Optional[str] = None, + custom_id: Optional[str] = None, + disabled: bool = False, + style: ButtonStyle = ButtonStyle.secondary, + emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, + id: Optional[int] = None, + ) -> Callable[[ItemCallbackType[S, Button[V]]], Button[V]]: + """A decorator that attaches a button to the action row. + + The function being decorated should have three parameters, ``self`` representing + the :class:`discord.ui.ActionRow`, the :class:`discord.Interaction` you receive and + the :class:`discord.ui.Button` being pressed. + .. note:: + + Buttons with a URL or a SKU cannot be created with this function. + Consider creating a :class:`Button` manually and adding it via + :meth:`ActionRow.add_item` instead. This is beacuse these buttons + cannot have a callback associated with them since Discord does not + do any processing with them. + + Parameters + ---------- + label: Optional[:class:`str`] + The label of the button, if any. + Can only be up to 80 characters. + custom_id: Optional[:class:`str`] + The ID of the button that gets received during an interaction. + It is recommended to not set this parameters to prevent conflicts. + Can only be up to 100 characters. + style: :class:`.ButtonStyle` + The style of the button. Defaults to :attr:`.ButtonStyle.grey`. + disabled: :class:`bool` + Whether the button is disabled or not. Defaults to ``False``. + emoji: Optional[Union[:class:`str`, :class:`.Emoji`, :class:`.PartialEmoji`]] + The emoji of the button. This can be in string form or a :class:`.PartialEmoji` + or a full :class:`.Emoji`. + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 + """ + + def decorator(func: ItemCallbackType[S, Button[V]]) -> ItemCallbackType[S, Button[V]]: + ret = _button( + label=label, + custom_id=custom_id, + disabled=disabled, + style=style, + emoji=emoji, + row=None, + id=id, + )(func) + ret.__discord_ui_parent__ = self # type: ignore + return ret # type: ignore + + return decorator # type: ignore + + @overload + def select( + self, + *, + cls: Type[SelectT] = Select[Any], + options: List[SelectOption] = MISSING, + channel_types: List[ChannelType] = ..., + placeholder: Optional[str] = ..., + custom_id: str = ..., + min_values: int = ..., + max_values: int = ..., + disabled: bool = ..., + id: Optional[int] = ..., + ) -> SelectCallbackDecorator[S, SelectT]: + ... + + @overload + def select( + self, + *, + cls: Type[UserSelectT] = UserSelect[Any], + options: List[SelectOption] = MISSING, + channel_types: List[ChannelType] = ..., + placeholder: Optional[str] = ..., + custom_id: str = ..., + min_values: int = ..., + max_values: int = ..., + disabled: bool = ..., + default_values: Sequence[ValidDefaultValues] = ..., + id: Optional[int] = ..., + ) -> SelectCallbackDecorator[S, UserSelectT]: + ... + + @overload + def select( + self, + *, + cls: Type[RoleSelectT] = RoleSelect[Any], + options: List[SelectOption] = MISSING, + channel_types: List[ChannelType] = ..., + placeholder: Optional[str] = ..., + custom_id: str = ..., + min_values: int = ..., + max_values: int = ..., + disabled: bool = ..., + default_values: Sequence[ValidDefaultValues] = ..., + id: Optional[int] = ..., + ) -> SelectCallbackDecorator[S, RoleSelectT]: + ... + + @overload + def select( + self, + *, + cls: Type[ChannelSelectT] = ChannelSelect[Any], + options: List[SelectOption] = MISSING, + channel_types: List[ChannelType] = ..., + placeholder: Optional[str] = ..., + custom_id: str = ..., + min_values: int = ..., + max_values: int = ..., + disabled: bool = ..., + default_values: Sequence[ValidDefaultValues] = ..., + id: Optional[int] = ..., + ) -> SelectCallbackDecorator[S, ChannelSelectT]: + ... + + @overload + def select( + self, + *, + cls: Type[MentionableSelectT] = MentionableSelect[Any], + options: List[SelectOption] = MISSING, + channel_types: List[ChannelType] = MISSING, + placeholder: Optional[str] = ..., + custom_id: str = ..., + min_values: int = ..., + max_values: int = ..., + disabled: bool = ..., + default_values: Sequence[ValidDefaultValues] = ..., + id: Optional[int] = ..., + ) -> SelectCallbackDecorator[S, MentionableSelectT]: + ... + + def select( + self, + *, + cls: Type[BaseSelectT] = Select[Any], + options: List[SelectOption] = MISSING, + channel_types: List[ChannelType] = MISSING, + placeholder: Optional[str] = None, + custom_id: str = MISSING, + min_values: int = 1, + max_values: int = 1, + disabled: bool = False, + default_values: Sequence[ValidDefaultValues] = MISSING, + id: Optional[int] = None, + ) -> SelectCallbackDecorator[S, BaseSelectT]: + """A decorator that attaches a select menu to the action row. + + The function being decorated should have three parameters, ``self`` representing + the :class:`discord.ui.ActionRow`, the :class:`discord.Interaction` you receive and + the chosen select class. + + To obtain the selected values inside the callback, you can use the ``values`` attribute of the chosen class in the callback. The list of values + will depend on the type of select menu used. View the table below for more information. + + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------+ + | Select Type | Resolved Values | + +========================================+=================================================================================================================+ + | :class:`discord.ui.Select` | List[:class:`str`] | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------+ + | :class:`discord.ui.UserSelect` | List[Union[:class:`discord.Member`, :class:`discord.User`]] | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------+ + | :class:`discord.ui.RoleSelect` | List[:class:`discord.Role`] | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------+ + | :class:`discord.ui.MentionableSelect` | List[Union[:class:`discord.Role`, :class:`discord.Member`, :class:`discord.User`]] | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------+ + | :class:`discord.ui.ChannelSelect` | List[Union[:class:`~discord.app_commands.AppCommandChannel`, :class:`~discord.app_commands.AppCommandThread`]] | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------+ + + Example + --------- + .. code-block:: python3 + + class MyView(discord.ui.LayoutView): + action_row = discord.ui.ActionRow() + + @action_row.select(cls=ChannelSelect, channel_types=[discord.ChannelType.text]) + async def select_channels(self, interaction: discord.Interaction, select: ChannelSelect): + return await interaction.response.send_message(f'You selected {select.values[0].mention}') + + Parameters + ------------ + cls: Union[Type[:class:`discord.ui.Select`], Type[:class:`discord.ui.UserSelect`], Type[:class:`discord.ui.RoleSelect`], \ + Type[:class:`discord.ui.MentionableSelect`], Type[:class:`discord.ui.ChannelSelect`]] + The class to use for the select menu. Defaults to :class:`discord.ui.Select`. You can use other + select types to display different select menus to the user. See the table above for the different + values you can get from each select type. Subclasses work as well, however the callback in the subclass will + get overridden. + placeholder: Optional[:class:`str`] + The placeholder text that is shown if nothing is selected, if any. + Can only be up to 150 characters. + custom_id: :class:`str` + The ID of the select menu that gets received during an interaction. + It is recommended not to set this parameter to prevent conflicts. + Can only be up to 100 characters. + min_values: :class:`int` + The minimum number of items that must be chosen for this select menu. + Defaults to 1 and must be between 0 and 25. + max_values: :class:`int` + The maximum number of items that must be chosen for this select menu. + Defaults to 1 and must be between 1 and 25. + options: List[:class:`discord.SelectOption`] + A list of options that can be selected in this menu. This can only be used with + :class:`Select` instances. + Can only contain up to 25 items. + channel_types: List[:class:`~discord.ChannelType`] + The types of channels to show in the select menu. Defaults to all channels. This can only be used + with :class:`ChannelSelect` instances. + disabled: :class:`bool` + Whether the select is disabled or not. Defaults to ``False``. + default_values: Sequence[:class:`~discord.abc.Snowflake`] + A list of objects representing the default values for the select menu. This cannot be used with regular :class:`Select` instances. + If ``cls`` is :class:`MentionableSelect` and :class:`.Object` is passed, then the type must be specified in the constructor. + Number of items must be in range of ``min_values`` and ``max_values``. + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 + """ + + def decorator(func: ItemCallbackType[S, BaseSelectT]) -> ItemCallbackType[S, BaseSelectT]: + r = _select( # type: ignore + cls=cls, # type: ignore + placeholder=placeholder, + custom_id=custom_id, + min_values=min_values, + max_values=max_values, + options=options, + channel_types=channel_types, + disabled=disabled, + default_values=default_values, + id=id, + )(func) + r.__discord_ui_parent__ = self + return r + + return decorator # type: ignore + + @classmethod + def from_component(cls, component: ActionRowComponent) -> ActionRow: + from .view import _component_to_item + + self = cls(id=component.id) + for cmp in component.children: + self.add_item(_component_to_item(cmp, self)) + return self diff --git a/discord/ui/button.py b/discord/ui/button.py index 43bd3a8b0f9d..97dba390cf00 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -24,12 +24,13 @@ from __future__ import annotations -from typing import Callable, Literal, Optional, TYPE_CHECKING, Tuple, TypeVar, Union +import copy +from typing import Any, Callable, Coroutine, Literal, Optional, TYPE_CHECKING, Tuple, TypeVar, Union import inspect import os -from .item import Item, ItemCallbackType +from .item import Item, I from ..enums import ButtonStyle, ComponentType from ..partial_emoji import PartialEmoji, _EmojiTag from ..components import Button as ButtonComponent @@ -42,11 +43,16 @@ if TYPE_CHECKING: from typing_extensions import Self - from .view import View + from .view import BaseView + from .action_row import ActionRow from ..emoji import Emoji + from ..interactions import Interaction from ..types.components import ButtonComponent as ButtonComponentPayload -V = TypeVar('V', bound='View', covariant=True) + ItemCallbackType = Callable[['S', Interaction[Any], I], Coroutine[Any, Any, Any]] + +S = TypeVar('S', bound='Union[BaseView, ActionRow]', covariant=True) +V = TypeVar('V', bound='BaseView', covariant=True) class Button(Item[V]): @@ -77,11 +83,19 @@ class Button(Item[V]): like to control the relative positioning of the row then passing an index is advised. For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic ordering. The row number must be between 0 and 4 (i.e. zero indexed). + + .. note:: + + This parameter is ignored when used in a :class:`ActionRow` or v2 component. sku_id: Optional[:class:`int`] The SKU ID this button sends you to. Can't be combined with ``url``, ``label``, ``emoji`` nor ``custom_id``. .. versionadded:: 2.4 + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. + + .. versionadded:: 2.6 """ __item_repr_attributes__: Tuple[str, ...] = ( @@ -92,6 +106,7 @@ class Button(Item[V]): 'emoji', 'row', 'sku_id', + 'id', ) def __init__( @@ -105,6 +120,7 @@ def __init__( emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, row: Optional[int] = None, sku_id: Optional[int] = None, + id: Optional[int] = None, ): super().__init__() if custom_id is not None and (url is not None or sku_id is not None): @@ -143,9 +159,19 @@ def __init__( style=style, emoji=emoji, sku_id=sku_id, + id=id, ) self.row = row + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this button.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value + @property def style(self) -> ButtonStyle: """:class:`discord.ButtonStyle`: The style of the button.""" @@ -242,6 +268,7 @@ def from_component(cls, button: ButtonComponent) -> Self: emoji=button.emoji, row=None, sku_id=button.sku_id, + id=button.id, ) @property @@ -262,6 +289,28 @@ def is_persistent(self) -> bool: def _refresh_component(self, button: ButtonComponent) -> None: self._underlying = button + def copy(self) -> Self: + new = copy.copy(self) + custom_id = self.custom_id + + if self.custom_id is not None and not self._provided_custom_id: + custom_id = os.urandom(16).hex() + + new._underlying = ButtonComponent._raw_construct( + custom_id=custom_id, + url=self.url, + disabled=self.disabled, + label=self.label, + style=self.style, + emoji=self.emoji, + sku_id=self.sku_id, + id=self.id, + ) + return new + + def __deepcopy__(self, memo) -> Self: + return self.copy() + def button( *, @@ -271,7 +320,8 @@ def button( style: ButtonStyle = ButtonStyle.secondary, emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, row: Optional[int] = None, -) -> Callable[[ItemCallbackType[V, Button[V]]], Button[V]]: + id: Optional[int] = None, +) -> Callable[[ItemCallbackType[S, Button[V]]], Button[V]]: """A decorator that attaches a button to a component. The function being decorated should have three parameters, ``self`` representing @@ -308,9 +358,17 @@ def button( like to control the relative positioning of the row then passing an index is advised. For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic ordering. The row number must be between 0 and 4 (i.e. zero indexed). + + .. note:: + + This parameter is ignored when used in a :class:`ActionRow` or v2 component. + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. + + .. versionadded:: 2.6 """ - def decorator(func: ItemCallbackType[V, Button[V]]) -> ItemCallbackType[V, Button[V]]: + def decorator(func: ItemCallbackType[S, Button[V]]) -> ItemCallbackType[S, Button[V]]: if not inspect.iscoroutinefunction(func): raise TypeError('button function must be a coroutine function') @@ -324,6 +382,7 @@ def decorator(func: ItemCallbackType[V, Button[V]]) -> ItemCallbackType[V, Butto 'emoji': emoji, 'row': row, 'sku_id': None, + 'id': id, } return func diff --git a/discord/ui/container.py b/discord/ui/container.py new file mode 100644 index 000000000000..a0d0a5f3211c --- /dev/null +++ b/discord/ui/container.py @@ -0,0 +1,369 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +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. +""" +from __future__ import annotations + +import copy +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ClassVar, + Coroutine, + Dict, + Generator, + List, + Literal, + Optional, + TypeVar, + Union, +) + +from .item import Item, I +from .view import _component_to_item, LayoutView +from ..enums import ComponentType +from ..utils import get as _utils_get +from ..colour import Colour, Color + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..components import Container as ContainerComponent + from ..interactions import Interaction + + ItemCallbackType = Callable[['S', Interaction[Any], I], Coroutine[Any, Any, Any]] + +S = TypeVar('S', bound='Container', covariant=True) +V = TypeVar('V', bound='LayoutView', covariant=True) + +__all__ = ('Container',) + + +class _ContainerCallback: + __slots__ = ('container', 'callback', 'item') + + def __init__(self, callback: ItemCallbackType[S, Any], container: Container, item: Item[Any]) -> None: + self.callback: ItemCallbackType[Any, Any] = callback + self.container: Container = container + self.item: Item[Any] = item + + def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: + return self.callback(self.container, interaction, self.item) + + +class Container(Item[V]): + r"""Represents a UI container. + + This is a top-level layout component that can only be used on :class:`LayoutView` + and can contain :class:`ActionRow`\s, :class:`TextDisplay`\s, :class:`Section`\s, + :class:`MediaGallery`\s, :class:`File`\s, and :class:`Separator`\s in it. + + This can be inherited. + + + .. versionadded:: 2.6 + + Examples + -------- + + .. code-block:: python3 + + import discord + from discord import ui + + # you can subclass it and add components as you would add them + # in a LayoutView + class MyContainer(ui.Container): + action_row = ui.ActionRow() + + @action_row.button(label='A button in a container!') + async def a_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_message('You clicked a button!') + + # or use it directly on LayoutView + class MyView(ui.LayoutView): + container = ui.Container(ui.TextDisplay('I am a text display on a container!')) + # or you can use your subclass: + # container = MyContainer() + + Parameters + ---------- + \*children: :class:`Item` + The initial children of this container. + accent_colour: Optional[Union[:class:`.Colour`, :class:`int`]] + The colour of the container. Defaults to ``None``. + accent_color: Optional[Union[:class:`.Colour`, :class:`int`]] + The color of the container. Defaults to ``None``. + spoiler: :class:`bool` + Whether to flag this container as a spoiler. Defaults + to ``False``. + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. + """ + + __container_children_items__: ClassVar[Dict[str, Union[ItemCallbackType[Self, Any], Item[Any]]]] = {} + __discord_ui_container__: ClassVar[bool] = True + __item_repr_attributes__ = ( + 'accent_colour', + 'spoiler', + 'id', + ) + + def __init__( + self, + *children: Item[V], + accent_colour: Optional[Union[Colour, int]] = None, + accent_color: Optional[Union[Color, int]] = None, + spoiler: bool = False, + id: Optional[int] = None, + ) -> None: + super().__init__() + self._children: List[Item[V]] = self._init_children() + for child in children: + self.add_item(child) + + self.spoiler: bool = spoiler + self._colour = accent_colour if accent_colour is not None else accent_color + self.id = id + + def __repr__(self) -> str: + return f'<{self.__class__.__name__} children={len(self._children)}>' + + def _init_children(self) -> List[Item[Any]]: + children = [] + parents = {} + + for name, raw in self.__container_children_items__.items(): + if isinstance(raw, Item): + item = raw.copy() + item._parent = self + setattr(self, name, item) + children.append(item) + parents[raw] = item + else: + # action rows can be created inside containers, and then callbacks can exist here + # so we create items based off them + item: Item = raw.__discord_ui_model_type__(**raw.__discord_ui_model_kwargs__) + item.callback = _ContainerCallback(raw, self, item) # type: ignore + setattr(self, raw.__name__, item) + # this should not fail because in order for a function to be here it should be from + # an action row and must have passed the check in __init_subclass__, but still + # guarding it + parent = getattr(raw, '__discord_ui_parent__', None) + if parent is None: + raise ValueError(f'{raw.__name__} is not a valid item for a Container') + parents.get(parent, parent)._children.append(item) + # we do not append it to the children list because technically these buttons and + # selects are not from the container but the action row itself. + + return children + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + children: Dict[str, Union[ItemCallbackType[Self, Any], Item[Any]]] = {} + for base in reversed(cls.__mro__): + for name, member in base.__dict__.items(): + if isinstance(member, Item): + children[name] = member + if hasattr(member, '__discord_ui_model_type__') and getattr(member, '__discord_ui_parent__', None): + children[name] = copy.copy(member) + + cls.__container_children_items__ = children + + def _update_view(self, view) -> bool: + self._view = view + for child in self._children: + child._update_view(view) + return True + + def _has_children(self): + return True + + @property + def children(self) -> List[Item[V]]: + """List[:class:`Item`]: The children of this container.""" + return self._children.copy() + + @children.setter + def children(self, value: List[Item[V]]) -> None: + self._children = value + + @property + def accent_colour(self) -> Optional[Union[Colour, int]]: + """Optional[Union[:class:`discord.Colour`, :class:`int`]]: The colour of the container, or ``None``.""" + return self._colour + + @accent_colour.setter + def accent_colour(self, value: Optional[Union[Colour, int]]) -> None: + if value is not None and not isinstance(value, (int, Colour)): + raise TypeError(f'expected an int, or Colour, not {value.__class__.__name__!r}') + + self._colour = value + + accent_color = accent_colour + + @property + def type(self) -> Literal[ComponentType.container]: + return ComponentType.container + + @property + def width(self): + return 5 + + def _is_v2(self) -> bool: + return True + + def to_components(self) -> List[Dict[str, Any]]: + components = [] + for i in self._children: + components.append(i.to_component_dict()) + return components + + def to_component_dict(self) -> Dict[str, Any]: + components = self.to_components() + + colour = None + if self._colour: + colour = self._colour if isinstance(self._colour, int) else self._colour.value + + base = { + 'type': self.type.value, + 'accent_color': colour, + 'spoiler': self.spoiler, + 'components': components, + } + if self.id is not None: + base['id'] = self.id + return base + + @classmethod + def from_component(cls, component: ContainerComponent) -> Self: + self = cls( + accent_colour=component.accent_colour, + spoiler=component.spoiler, + id=component.id, + ) + self._children = [_component_to_item(cmp, self) for cmp in component.children] + return self + + def walk_children(self) -> Generator[Item[V], None, None]: + """An iterator that recursively walks through all the children of this container + and its children, if applicable. + + Yields + ------ + :class:`Item` + An item in the container. + """ + + for child in self.children: + yield child + + if child._has_children(): + yield from child.walk_children() # type: ignore + + def add_item(self, item: Item[Any]) -> Self: + """Adds an item to this container. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + item: :class:`Item` + The item to append. + + Raises + ------ + TypeError + An :class:`Item` was not passed. + """ + if not isinstance(item, Item): + raise TypeError(f'expected Item not {item.__class__.__name__}') + + self._children.append(item) + item._update_view(self.view) + item._parent = self + + if item._has_children() and self._view: + self._view._total_children += len(tuple(item.walk_children())) # type: ignore + elif self._view: + self._view._total_children += 1 + return self + + def remove_item(self, item: Item[Any]) -> Self: + """Removes an item from this container. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + item: :class:`Item` + The item to remove from the container. + """ + + try: + self._children.remove(item) + except ValueError: + pass + else: + if self._view and self._view._is_layout(): + if item._has_children(): + self._view._total_children -= len(tuple(item.walk_children())) # type: ignore + else: + self._view._total_children -= 1 + return self + + def find_item(self, id: int, /) -> Optional[Item[V]]: + """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if + not found. + + .. warning:: + + This is **not the same** as ``custom_id``. + + Parameters + ---------- + id: :class:`int` + The ID of the component. + + Returns + ------- + Optional[:class:`Item`] + The item found, or ``None``. + """ + return _utils_get(self.walk_children(), id=id) + + def clear_items(self) -> Self: + """Removes all the items from the container. + + This function returns the class instance to allow for fluent-style + chaining. + """ + + if self._view and self._view._is_layout(): + self._view._total_children -= len(tuple(self.walk_children())) + self._children.clear() + return self diff --git a/discord/ui/dynamic.py b/discord/ui/dynamic.py index 0b65e90f3a35..fb38b4b2e0cb 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -38,14 +38,12 @@ from ..interactions import Interaction from ..components import Component from ..enums import ComponentType - from .view import View - - V = TypeVar('V', bound='View', covariant=True, default=View) + from .view import View, LayoutView else: - V = TypeVar('V', bound='View', covariant=True) + View = LayoutView = Any -class DynamicItem(Generic[BaseT], Item['View']): +class DynamicItem(Generic[BaseT], Item[Union[View, LayoutView]]): """Represents an item with a dynamic ``custom_id`` that can be used to store state within that ``custom_id``. @@ -57,9 +55,10 @@ class DynamicItem(Generic[BaseT], Item['View']): and should not be used long term. Their only purpose is to act as a "template" for the actual dispatched item. - When this item is generated, :attr:`view` is set to a regular :class:`View` instance - from the original message given from the interaction. This means that custom view - subclasses cannot be accessed from this item. + When this item is generated, :attr:`view` is set to a regular :class:`View` instance, + but to a :class:`LayoutView` if the component was sent with one, this is obtained from + the original message given from the interaction. This means that custom view subclasses + cannot be accessed from this item. .. versionadded:: 2.4 diff --git a/discord/ui/file.py b/discord/ui/file.py new file mode 100644 index 000000000000..746f18fe059c --- /dev/null +++ b/discord/ui/file.py @@ -0,0 +1,146 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +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. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, Optional, TypeVar, Union + +from .item import Item +from ..components import FileComponent, UnfurledMediaItem +from ..enums import ComponentType + +if TYPE_CHECKING: + from typing_extensions import Self + + from .view import LayoutView + +V = TypeVar('V', bound='LayoutView', covariant=True) + +__all__ = ('File',) + + +class File(Item[V]): + """Represents a UI file component. + + This is a top-level layout component that can only be used on :class:`LayoutView`. + + .. versionadded:: 2.6 + + Example + ------- + + .. code-block:: python3 + + import discord + from discord import ui + + class MyView(ui.LayoutView): + file = ui.File('attachment://file.txt') + # attachment://file.txt points to an attachment uploaded alongside this view + + Parameters + ---------- + media: Union[:class:`str`, :class:`.UnfurledMediaItem`] + This file's media. If this is a string it must point to a local + file uploaded within the parent view of this item, and must + meet the ``attachment://`` format. + spoiler: :class:`bool` + Whether to flag this file as a spoiler. Defaults to ``False``. + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. + """ + + __item_repr_attributes__ = ( + 'media', + 'spoiler', + 'id', + ) + + def __init__( + self, + media: Union[str, UnfurledMediaItem], + *, + spoiler: bool = False, + id: Optional[int] = None, + ) -> None: + super().__init__() + self._underlying = FileComponent._raw_construct( + media=UnfurledMediaItem(media) if isinstance(media, str) else media, + spoiler=spoiler, + id=id, + ) + self.id = id + + def _is_v2(self): + return True + + @property + def width(self): + return 5 + + @property + def type(self) -> Literal[ComponentType.file]: + return self._underlying.type + + @property + def media(self) -> UnfurledMediaItem: + """:class:`.UnfurledMediaItem`: Returns this file media.""" + return self._underlying.media + + @media.setter + def media(self, value: Union[str, UnfurledMediaItem]) -> None: + if isinstance(value, str): + self._underlying.media = UnfurledMediaItem(value) + elif isinstance(value, UnfurledMediaItem): + self._underlying.media = value + else: + raise TypeError(f'expected a str or UnfurledMediaItem, not {value.__class__.__name__!r}') + + @property + def url(self) -> str: + """:class:`str`: Returns this file's url.""" + return self._underlying.media.url + + @url.setter + def url(self, value: str) -> None: + self._underlying.media = UnfurledMediaItem(value) + + @property + def spoiler(self) -> bool: + """:class:`bool`: Returns whether this file should be flagged as a spoiler.""" + return self._underlying.spoiler + + @spoiler.setter + def spoiler(self, value: bool) -> None: + self._underlying.spoiler = value + + def to_component_dict(self): + return self._underlying.to_dict() + + @classmethod + def from_component(cls, component: FileComponent) -> Self: + return cls( + media=component.media, + spoiler=component.spoiler, + id=component.id, + ) diff --git a/discord/ui/item.py b/discord/ui/item.py index 1ee5492836b5..97f528cdb5ba 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -24,6 +24,7 @@ from __future__ import annotations +import copy from typing import Any, Callable, Coroutine, Dict, Generic, Optional, TYPE_CHECKING, Tuple, Type, TypeVar from ..interactions import Interaction @@ -36,12 +37,14 @@ # fmt: on if TYPE_CHECKING: + from typing_extensions import Self + from ..enums import ComponentType - from .view import View + from .view import BaseView from ..components import Component I = TypeVar('I', bound='Item[Any]') -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='BaseView', covariant=True) ItemCallbackType = Callable[[V, Interaction[Any], I], Coroutine[Any, Any, Any]] @@ -53,11 +56,19 @@ class Item(Generic[V]): - :class:`discord.ui.Button` - :class:`discord.ui.Select` - :class:`discord.ui.TextInput` + - :class:`discord.ui.ActionRow` + - :class:`discord.ui.Container` + - :class:`discord.ui.File` + - :class:`discord.ui.MediaGallery` + - :class:`discord.ui.Section` + - :class:`discord.ui.Separator` + - :class:`discord.ui.TextDisplay` + - :class:`discord.ui.Thumbnail` .. versionadded:: 2.0 """ - __item_repr_attributes__: Tuple[str, ...] = ('row',) + __item_repr_attributes__: Tuple[str, ...] = ('row', 'id') def __init__(self): self._view: Optional[V] = None @@ -70,6 +81,8 @@ def __init__(self): # actually affect the intended purpose of this check because from_component is # only called upon edit and we're mainly interested during initial creation time. self._provided_custom_id: bool = False + self._id: Optional[int] = None + self._parent: Optional[Item] = None def to_component_dict(self) -> Dict[str, Any]: raise NotImplementedError @@ -80,6 +93,9 @@ def _refresh_component(self, component: Component) -> None: def _refresh_state(self, interaction: Interaction, data: Dict[str, Any]) -> None: return None + def _is_v2(self) -> bool: + return False + @classmethod def from_component(cls: Type[I], component: Component) -> I: return cls() @@ -92,7 +108,9 @@ def is_dispatchable(self) -> bool: return False def is_persistent(self) -> bool: - return self._provided_custom_id + if self.is_dispatchable(): + return self._provided_custom_id + return True def __repr__(self) -> str: attrs = ' '.join(f'{key}={getattr(self, key)!r}' for key in self.__item_repr_attributes__) @@ -104,6 +122,10 @@ def row(self) -> Optional[int]: @row.setter def row(self, value: Optional[int]) -> None: + if self._is_v2(): + # row is ignored on v2 components + return + if value is None: self._row = None elif 5 > value >= 0: @@ -117,9 +139,45 @@ def width(self) -> int: @property def view(self) -> Optional[V]: - """Optional[:class:`View`]: The underlying view for this item.""" + """Optional[Union[:class:`View`, :class:`LayoutView`]]: The underlying view for this item.""" return self._view + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this component.""" + return self._id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._id = value + + @property + def parent(self) -> Optional[Item[V]]: + """Optional[:class:`Item`]: This item's parent. Only components that can have children + can be parents. Any item that has :class:`View` as a view will have this set to `None` + since only :class:`LayoutView` component v2 items can contain "container" like items. + + .. versionadded:: 2.6 + """ + return self._parent + + async def _run_checks(self, interaction: Interaction[ClientT]) -> bool: + can_run = await self.interaction_check(interaction) + + if can_run and self._parent: + can_run = await self._parent._run_checks(interaction) + + return can_run + + def _update_view(self, view) -> None: + self._view = view + + def copy(self) -> Self: + return copy.deepcopy(self) + + def _has_children(self) -> bool: + return False + async def callback(self, interaction: Interaction[ClientT]) -> Any: """|coro| diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py new file mode 100644 index 000000000000..8d9a1c9e1731 --- /dev/null +++ b/discord/ui/media_gallery.py @@ -0,0 +1,260 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +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. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, List, Literal, Optional, TypeVar, Union + +from .item import Item +from ..enums import ComponentType +from ..components import ( + MediaGalleryItem, + MediaGalleryComponent, + UnfurledMediaItem, +) + +if TYPE_CHECKING: + from typing_extensions import Self + + from .view import LayoutView + +V = TypeVar('V', bound='LayoutView', covariant=True) + +__all__ = ('MediaGallery',) + + +class MediaGallery(Item[V]): + r"""Represents a UI media gallery. + + Can contain up to 10 :class:`.MediaGalleryItem`\s. + + This is a top-level layout component that can only be used on :class:`LayoutView`. + + .. versionadded:: 2.6 + + Parameters + ---------- + \*items: :class:`.MediaGalleryItem` + The initial items of this gallery. + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. + """ + + __item_repr_attributes__ = ( + 'items', + 'id', + ) + + def __init__( + self, + *items: MediaGalleryItem, + id: Optional[int] = None, + ) -> None: + super().__init__() + + self._underlying = MediaGalleryComponent._raw_construct( + items=list(items), + id=id, + ) + + def __repr__(self) -> str: + return f'<{self.__class__.__name__} items={len(self._underlying.items)}>' + + @property + def items(self) -> List[MediaGalleryItem]: + """List[:class:`.MediaGalleryItem`]: Returns a read-only list of this gallery's items.""" + return self._underlying.items.copy() + + @items.setter + def items(self, value: List[MediaGalleryItem]) -> None: + if len(value) > 10: + raise ValueError('media gallery only accepts up to 10 items') + + self._underlying.items = value + + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this component.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value + + def to_component_dict(self): + return self._underlying.to_dict() + + def _is_v2(self) -> bool: + return True + + def add_item( + self, + *, + media: Union[str, UnfurledMediaItem], + description: Optional[str] = None, + spoiler: bool = False, + ) -> Self: + """Adds an item to this gallery. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + media: Union[:class:`str`, :class:`.UnfurledMediaItem`] + The media item data. This can be a string representing a local + file uploaded as an attachment in the message, which can be accessed + using the ``attachment://`` format, or an arbitrary url. + description: Optional[:class:`str`] + The description to show within this item. Up to 256 characters. Defaults + to ``None``. + spoiler: :class:`bool` + Whether this item should be flagged as a spoiler. Defaults to ``False``. + + Raises + ------ + ValueError + Maximum number of items has been exceeded (10). + """ + + if len(self._underlying.items) >= 10: + raise ValueError('maximum number of items has been exceeded') + + item = MediaGalleryItem(media, description=description, spoiler=spoiler) + self._underlying.items.append(item) + return self + + def append_item(self, item: MediaGalleryItem) -> Self: + """Appends an item to this gallery. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + item: :class:`.MediaGalleryItem` + The item to add to the gallery. + + Raises + ------ + TypeError + A :class:`.MediaGalleryItem` was not passed. + ValueError + Maximum number of items has been exceeded (10). + """ + + if len(self._underlying.items) >= 10: + raise ValueError('maximum number of items has been exceeded') + + if not isinstance(item, MediaGalleryItem): + raise TypeError(f'expected MediaGalleryItem, not {item.__class__.__name__!r}') + + self._underlying.items.append(item) + return self + + def insert_item_at( + self, + index: int, + *, + media: Union[str, UnfurledMediaItem], + description: Optional[str] = None, + spoiler: bool = False, + ) -> Self: + """Inserts an item before a specified index to the media gallery. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + index: :class:`int` + The index of where to insert the field. + media: Union[:class:`str`, :class:`.UnfurledMediaItem`] + The media item data. This can be a string representing a local + file uploaded as an attachment in the message, which can be accessed + using the ``attachment://`` format, or an arbitrary url. + description: Optional[:class:`str`] + The description to show within this item. Up to 256 characters. Defaults + to ``None``. + spoiler: :class:`bool` + Whether this item should be flagged as a spoiler. Defaults to ``False``. + + Raises + ------ + ValueError + Maximum number of items has been exceeded (10). + """ + + if len(self._underlying.items) >= 10: + raise ValueError('maximum number of items has been exceeded') + + item = MediaGalleryItem( + media, + description=description, + spoiler=spoiler, + ) + self._underlying.items.insert(index, item) + return self + + def remove_item(self, item: MediaGalleryItem) -> Self: + """Removes an item from the gallery. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + item: :class:`.MediaGalleryItem` + The item to remove from the gallery. + """ + + try: + self._underlying.items.remove(item) + except ValueError: + pass + return self + + def clear_items(self) -> Self: + """Removes all items from the gallery. + + This function returns the class instance to allow for fluent-style + chaining. + """ + + self._underlying.items.clear() + return self + + @property + def type(self) -> Literal[ComponentType.media_gallery]: + return self._underlying.type + + @property + def width(self): + return 5 + + @classmethod + def from_component(cls, component: MediaGalleryComponent) -> Self: + return cls( + *component.items, + id=component.id, + ) diff --git a/discord/ui/section.py b/discord/ui/section.py new file mode 100644 index 000000000000..745f91ab3d63 --- /dev/null +++ b/discord/ui/section.py @@ -0,0 +1,248 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +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. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Generator, List, Literal, Optional, TypeVar, Union, ClassVar + +from .item import Item +from .text_display import TextDisplay +from ..enums import ComponentType +from ..utils import MISSING, get as _utils_get + +if TYPE_CHECKING: + from typing_extensions import Self + + from .view import LayoutView + from ..components import SectionComponent + +V = TypeVar('V', bound='LayoutView', covariant=True) + +__all__ = ('Section',) + + +class Section(Item[V]): + r"""Represents a UI section. + + This is a top-level layout component that can only be used on :class:`LayoutView`. + + .. versionadded:: 2.6 + + Parameters + ---------- + \*children: Union[:class:`str`, :class:`TextDisplay`] + The text displays of this section. Up to 3. + accessory: :class:`Item` + The section accessory. + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. + """ + + __item_repr_attributes__ = ( + 'accessory', + 'id', + ) + __discord_ui_section__: ClassVar[bool] = True + + __slots__ = ( + '_children', + 'accessory', + ) + + def __init__( + self, + *children: Union[Item[V], str], + accessory: Item[V], + id: Optional[int] = None, + ) -> None: + super().__init__() + self._children: List[Item[V]] = [] + if children: + if len(children) > 3: + raise ValueError('maximum number of children exceeded') + self._children.extend( + [c if isinstance(c, Item) else TextDisplay(c) for c in children], + ) + self.accessory: Item[V] = accessory + self.id = id + + def __repr__(self) -> str: + return f'<{self.__class__.__name__} children={len(self._children)}>' + + @property + def type(self) -> Literal[ComponentType.section]: + return ComponentType.section + + @property + def children(self) -> List[Item[V]]: + """List[:class:`Item`]: The list of children attached to this section.""" + return self._children.copy() + + @property + def width(self): + return 5 + + def _is_v2(self) -> bool: + return True + + def walk_children(self) -> Generator[Item[V], None, None]: + """An iterator that recursively walks through all the children of this section + and its children, if applicable. This includes the `accessory`. + + Yields + ------ + :class:`Item` + An item in this section. + """ + + for child in self.children: + yield child + yield self.accessory + + def _update_view(self, view) -> None: + self._view = view + self.accessory._view = view + for child in self._children: + child._view = view + + def _has_children(self): + return True + + def add_item(self, item: Union[str, Item[Any]]) -> Self: + """Adds an item to this section. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + item: Union[:class:`str`, :class:`Item`] + The item to append, if it is a string it automatically wrapped around + :class:`TextDisplay`. + + Raises + ------ + TypeError + An :class:`Item` or :class:`str` was not passed. + ValueError + Maximum number of children has been exceeded (3). + """ + + if len(self._children) >= 3: + raise ValueError('maximum number of children exceeded') + + if not isinstance(item, (Item, str)): + raise TypeError(f'expected Item or str not {item.__class__.__name__}') + + item = item if isinstance(item, Item) else TextDisplay(item) + item._update_view(self.view) + item._parent = self + self._children.append(item) + + if self._view: + self._view._total_children += 1 + + return self + + def remove_item(self, item: Item[Any]) -> Self: + """Removes an item from this section. + + This function returns the class instance to allow for fluent-style + chaining. + + Parameters + ---------- + item: :class:`Item` + The item to remove from the section. + """ + + try: + self._children.remove(item) + except ValueError: + pass + else: + if self._view: + self._view._total_children -= 1 + + return self + + def find_item(self, id: int, /) -> Optional[Item[V]]: + """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if + not found. + + .. warning:: + + This is **not the same** as ``custom_id``. + + Parameters + ---------- + id: :class:`int` + The ID of the component. + + Returns + ------- + Optional[:class:`Item`] + The item found, or ``None``. + """ + return _utils_get(self.walk_children(), id=id) + + def clear_items(self) -> Self: + """Removes all the items from the section. + + This function returns the class instance to allow for fluent-style + chaining. + """ + if self._view and self._view._is_layout(): + self._view._total_children -= len(self._children) # we don't count the accessory because it is required + + self._children.clear() + return self + + @classmethod + def from_component(cls, component: SectionComponent) -> Self: + from .view import _component_to_item + + # using MISSING as accessory so we can create the new one with the parent set + self = cls(id=component.id, accessory=MISSING) + self.accessory = _component_to_item(component.accessory, self) + self.id = component.id + self._children = [_component_to_item(c, self) for c in component.children] + + return self + + def to_components(self) -> List[Dict[str, Any]]: + components = [] + + for component in self._children: + components.append(component.to_component_dict()) + return components + + def to_component_dict(self) -> Dict[str, Any]: + data = { + 'type': self.type.value, + 'components': self.to_components(), + 'accessory': self.accessory.to_component_dict(), + } + if self.id is not None: + data['id'] = self.id + return data diff --git a/discord/ui/select.py b/discord/ui/select.py index 1ef085cc5df2..55596ec6bad9 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -25,6 +25,7 @@ from __future__ import annotations from typing import ( Any, + Coroutine, List, Literal, Optional, @@ -42,7 +43,7 @@ import inspect import os -from .item import Item, ItemCallbackType +from .item import Item, I from ..enums import ChannelType, ComponentType, SelectDefaultValueType from ..partial_emoji import PartialEmoji from ..emoji import Emoji @@ -72,7 +73,8 @@ if TYPE_CHECKING: from typing_extensions import TypeAlias, TypeGuard - from .view import View + from .view import BaseView + from .action_row import ActionRow from ..types.components import SelectMenu as SelectMenuPayload from ..types.interactions import SelectMessageComponentInteractionData from ..app_commands import AppCommandChannel, AppCommandThread @@ -101,14 +103,17 @@ Thread, ] -V = TypeVar('V', bound='View', covariant=True) + ItemCallbackType = Callable[['S', Interaction[Any], I], Coroutine[Any, Any, Any]] + +S = TypeVar('S', bound='Union[BaseView, ActionRow]', covariant=True) +V = TypeVar('V', bound='BaseView', covariant=True) BaseSelectT = TypeVar('BaseSelectT', bound='BaseSelect[Any]') SelectT = TypeVar('SelectT', bound='Select[Any]') UserSelectT = TypeVar('UserSelectT', bound='UserSelect[Any]') RoleSelectT = TypeVar('RoleSelectT', bound='RoleSelect[Any]') ChannelSelectT = TypeVar('ChannelSelectT', bound='ChannelSelect[Any]') MentionableSelectT = TypeVar('MentionableSelectT', bound='MentionableSelect[Any]') -SelectCallbackDecorator: TypeAlias = Callable[[ItemCallbackType[V, BaseSelectT]], BaseSelectT] +SelectCallbackDecorator: TypeAlias = Callable[['ItemCallbackType[S, BaseSelectT]'], BaseSelectT] DefaultSelectComponentTypes = Literal[ ComponentType.user_select, ComponentType.role_select, @@ -216,6 +221,7 @@ class BaseSelect(Item[V]): 'min_values', 'max_values', 'disabled', + 'id', ) __component_attributes__: Tuple[str, ...] = ( 'custom_id', @@ -223,6 +229,7 @@ class BaseSelect(Item[V]): 'min_values', 'max_values', 'disabled', + 'id', ) def __init__( @@ -238,6 +245,7 @@ def __init__( options: List[SelectOption] = MISSING, channel_types: List[ChannelType] = MISSING, default_values: Sequence[SelectDefaultValue] = MISSING, + id: Optional[int] = None, ) -> None: super().__init__() self._provided_custom_id = custom_id is not MISSING @@ -255,11 +263,21 @@ def __init__( channel_types=[] if channel_types is MISSING else channel_types, options=[] if options is MISSING else options, default_values=[] if default_values is MISSING else default_values, + id=id, ) self.row = row self._values: List[PossibleValue] = [] + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this select.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value + @property def values(self) -> List[PossibleValue]: values = selected_values.get({}) @@ -390,6 +408,14 @@ class Select(BaseSelect[V]): like to control the relative positioning of the row then passing an index is advised. For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic ordering. The row number must be between 0 and 4 (i.e. zero indexed). + + .. note:: + + This parameter is ignored when used in a :class:`ActionRow` or v2 component. + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ __component_attributes__ = BaseSelect.__component_attributes__ + ('options',) @@ -404,6 +430,7 @@ def __init__( options: List[SelectOption] = MISSING, disabled: bool = False, row: Optional[int] = None, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -414,6 +441,7 @@ def __init__( disabled=disabled, options=options, row=row, + id=id, ) @property @@ -545,6 +573,14 @@ class UserSelect(BaseSelect[V]): like to control the relative positioning of the row then passing an index is advised. For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic ordering. The row number must be between 0 and 4 (i.e. zero indexed). + + .. note:: + + This parameter is ignored when used in a :class:`ActionRow` or v2 component. + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ __component_attributes__ = BaseSelect.__component_attributes__ + ('default_values',) @@ -559,6 +595,7 @@ def __init__( disabled: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -569,6 +606,7 @@ def __init__( disabled=disabled, row=row, default_values=_handle_select_defaults(default_values, self.type), + id=id, ) @property @@ -637,6 +675,14 @@ class RoleSelect(BaseSelect[V]): like to control the relative positioning of the row then passing an index is advised. For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic ordering. The row number must be between 0 and 4 (i.e. zero indexed). + + .. note:: + + This parameter is ignored when used in a :class:`ActionRow` or v2 component. + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ __component_attributes__ = BaseSelect.__component_attributes__ + ('default_values',) @@ -651,6 +697,7 @@ def __init__( disabled: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -661,6 +708,7 @@ def __init__( disabled=disabled, row=row, default_values=_handle_select_defaults(default_values, self.type), + id=id, ) @property @@ -725,6 +773,14 @@ class MentionableSelect(BaseSelect[V]): like to control the relative positioning of the row then passing an index is advised. For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic ordering. The row number must be between 0 and 4 (i.e. zero indexed). + + .. note:: + + This parameter is ignored when used in a :class:`ActionRow` or v2 component. + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ __component_attributes__ = BaseSelect.__component_attributes__ + ('default_values',) @@ -739,6 +795,7 @@ def __init__( disabled: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -749,6 +806,7 @@ def __init__( disabled=disabled, row=row, default_values=_handle_select_defaults(default_values, self.type), + id=id, ) @property @@ -819,6 +877,14 @@ class ChannelSelect(BaseSelect[V]): like to control the relative positioning of the row then passing an index is advised. For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic ordering. The row number must be between 0 and 4 (i.e. zero indexed). + + .. note:: + + This parameter is ignored when used in a :class:`ActionRow` or v2 component. + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ __component_attributes__ = BaseSelect.__component_attributes__ + ( @@ -837,6 +903,7 @@ def __init__( disabled: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -848,6 +915,7 @@ def __init__( row=row, channel_types=channel_types, default_values=_handle_select_defaults(default_values, self.type), + id=id, ) @property @@ -899,7 +967,8 @@ def select( max_values: int = ..., disabled: bool = ..., row: Optional[int] = ..., -) -> SelectCallbackDecorator[V, SelectT]: + id: Optional[int] = ..., +) -> SelectCallbackDecorator[S, SelectT]: ... @@ -916,7 +985,8 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., -) -> SelectCallbackDecorator[V, UserSelectT]: + id: Optional[int] = ..., +) -> SelectCallbackDecorator[S, UserSelectT]: ... @@ -933,7 +1003,8 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., -) -> SelectCallbackDecorator[V, RoleSelectT]: + id: Optional[int] = ..., +) -> SelectCallbackDecorator[S, RoleSelectT]: ... @@ -950,7 +1021,8 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., -) -> SelectCallbackDecorator[V, ChannelSelectT]: + id: Optional[int] = ..., +) -> SelectCallbackDecorator[S, ChannelSelectT]: ... @@ -967,7 +1039,8 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., -) -> SelectCallbackDecorator[V, MentionableSelectT]: + id: Optional[int] = ..., +) -> SelectCallbackDecorator[S, MentionableSelectT]: ... @@ -983,7 +1056,8 @@ def select( disabled: bool = False, default_values: Sequence[ValidDefaultValues] = MISSING, row: Optional[int] = None, -) -> SelectCallbackDecorator[V, BaseSelectT]: + id: Optional[int] = None, +) -> SelectCallbackDecorator[S, BaseSelectT]: """A decorator that attaches a select menu to a component. The function being decorated should have three parameters, ``self`` representing @@ -1041,6 +1115,10 @@ async def select_channels(self, interaction: discord.Interaction, select: Channe like to control the relative positioning of the row then passing an index is advised. For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic ordering. The row number must be between 0 and 4 (i.e. zero indexed). + + .. note:: + + This parameter is ignored when used in a :class:`ActionRow` or v2 component. min_values: :class:`int` The minimum number of items that must be chosen for this select menu. Defaults to 1 and must be between 0 and 25. @@ -1062,9 +1140,13 @@ async def select_channels(self, interaction: discord.Interaction, select: Channe Number of items must be in range of ``min_values`` and ``max_values``. .. versionadded:: 2.4 + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ - def decorator(func: ItemCallbackType[V, BaseSelectT]) -> ItemCallbackType[V, BaseSelectT]: + def decorator(func: ItemCallbackType[S, BaseSelectT]) -> ItemCallbackType[S, BaseSelectT]: if not inspect.iscoroutinefunction(func): raise TypeError('select function must be a coroutine function') callback_cls = getattr(cls, '__origin__', cls) @@ -1080,6 +1162,7 @@ def decorator(func: ItemCallbackType[V, BaseSelectT]) -> ItemCallbackType[V, Bas 'min_values': min_values, 'max_values': max_values, 'disabled': disabled, + 'id': id, } if issubclass(callback_cls, Select): func.__discord_ui_model_kwargs__['options'] = options diff --git a/discord/ui/separator.py b/discord/ui/separator.py new file mode 100644 index 000000000000..e9ba7d789c5d --- /dev/null +++ b/discord/ui/separator.py @@ -0,0 +1,124 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +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. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, Optional, TypeVar + +from .item import Item +from ..components import SeparatorComponent +from ..enums import SeparatorSpacing, ComponentType + +if TYPE_CHECKING: + from typing_extensions import Self + + from .view import LayoutView + +V = TypeVar('V', bound='LayoutView', covariant=True) + +__all__ = ('Separator',) + + +class Separator(Item[V]): + """Represents a UI separator. + + This is a top-level layout component that can only be used on :class:`LayoutView`. + + .. versionadded:: 2.6 + + Parameters + ---------- + visible: :class:`bool` + Whether this separator is visible. On the client side this + is whether a divider line should be shown or not. + spacing: :class:`.SeparatorSpacing` + The spacing of this separator. + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. + """ + + __slots__ = ('_underlying',) + __item_repr_attributes__ = ( + 'visible', + 'spacing', + 'id', + ) + + def __init__( + self, + *, + visible: bool = True, + spacing: SeparatorSpacing = SeparatorSpacing.small, + id: Optional[int] = None, + ) -> None: + super().__init__() + self._underlying = SeparatorComponent._raw_construct( + spacing=spacing, + visible=visible, + id=id, + ) + self.id = id + + def _is_v2(self): + return True + + @property + def visible(self) -> bool: + """:class:`bool`: Whether this separator is visible. + + On the client side this is whether a divider line should + be shown or not. + """ + return self._underlying.visible + + @visible.setter + def visible(self, value: bool) -> None: + self._underlying.visible = value + + @property + def spacing(self) -> SeparatorSpacing: + """:class:`.SeparatorSpacing`: The spacing of this separator.""" + return self._underlying.spacing + + @spacing.setter + def spacing(self, value: SeparatorSpacing) -> None: + self._underlying.spacing = value + + @property + def width(self): + return 5 + + @property + def type(self) -> Literal[ComponentType.separator]: + return self._underlying.type + + def to_component_dict(self): + return self._underlying.to_dict() + + @classmethod + def from_component(cls, component: SeparatorComponent) -> Self: + return cls( + visible=component.visible, + spacing=component.spacing, + id=component.id, + ) diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py new file mode 100644 index 000000000000..399428a06b85 --- /dev/null +++ b/discord/ui/text_display.py @@ -0,0 +1,89 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +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. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, Optional, TypeVar + +from .item import Item +from ..components import TextDisplay as TextDisplayComponent +from ..enums import ComponentType + +if TYPE_CHECKING: + from typing_extensions import Self + + from .view import LayoutView + +V = TypeVar('V', bound='LayoutView', covariant=True) + +__all__ = ('TextDisplay',) + + +class TextDisplay(Item[V]): + """Represents a UI text display. + + This is a top-level layout component that can only be used on :class:`LayoutView` or :class:`Section`. + + .. versionadded:: 2.6 + + Parameters + ---------- + content: :class:`str` + The content of this text display. Up to 4000 characters. + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. + """ + + __slots__ = ('content',) + + def __init__(self, content: str, *, row: Optional[int] = None, id: Optional[int] = None) -> None: + super().__init__() + self.content: str = content + self.id = id + + def to_component_dict(self): + base = { + 'type': self.type.value, + 'content': self.content, + } + if self.id is not None: + base['id'] = self.id + return base + + @property + def width(self): + return 5 + + @property + def type(self) -> Literal[ComponentType.text_display]: + return ComponentType.text_display + + def _is_v2(self) -> bool: + return True + + @classmethod + def from_component(cls, component: TextDisplayComponent) -> Self: + return cls( + content=component.content, + id=component.id, + ) diff --git a/discord/ui/text_input.py b/discord/ui/text_input.py index 96b4581f40b0..218d7c4d090a 100644 --- a/discord/ui/text_input.py +++ b/discord/ui/text_input.py @@ -92,12 +92,17 @@ class TextInput(Item[V]): like to control the relative positioning of the row then passing an index is advised. For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic ordering. The row number must be between 0 and 4 (i.e. zero indexed). + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + .. versionadded:: 2.6 """ __item_repr_attributes__: Tuple[str, ...] = ( 'label', 'placeholder', 'required', + 'id', ) def __init__( @@ -112,6 +117,7 @@ def __init__( min_length: Optional[int] = None, max_length: Optional[int] = None, row: Optional[int] = None, + id: Optional[int] = None, ) -> None: super().__init__() self._value: Optional[str] = default @@ -129,8 +135,10 @@ def __init__( required=required, min_length=min_length, max_length=max_length, + id=id, ) self.row = row + self.id = id def __str__(self) -> str: return self.value @@ -241,6 +249,7 @@ def from_component(cls, component: TextInputComponent) -> Self: min_length=component.min_length, max_length=component.max_length, row=None, + id=component.id, ) @property diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py new file mode 100644 index 000000000000..855b27a27abd --- /dev/null +++ b/discord/ui/thumbnail.py @@ -0,0 +1,132 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +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. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, TypeVar, Union + +from .item import Item +from ..enums import ComponentType +from ..components import UnfurledMediaItem + +if TYPE_CHECKING: + from typing_extensions import Self + + from .view import LayoutView + from ..components import ThumbnailComponent + +V = TypeVar('V', bound='LayoutView', covariant=True) + +__all__ = ('Thumbnail',) + + +class Thumbnail(Item[V]): + """Represents a UI Thumbnail. This currently can only be used as a :class:`Section`\'s accessory. + + .. versionadded:: 2.6 + + Parameters + ---------- + media: Union[:class:`str`, :class:`discord.UnfurledMediaItem`] + The media of the thumbnail. This can be a URL or a reference + to an attachment that matches the ``attachment://filename.extension`` + structure. + description: Optional[:class:`str`] + The description of this thumbnail. Up to 256 characters. Defaults to ``None``. + spoiler: :class:`bool` + Whether to flag this thumbnail as a spoiler. Defaults to ``False``. + id: Optional[:class:`int`] + The ID of this component. This must be unique across the view. + """ + + __slots__ = ( + '_media', + 'description', + 'spoiler', + ) + __item_repr_attributes__ = ( + 'media', + 'description', + 'spoiler', + 'row', + 'id', + ) + + def __init__( + self, + media: Union[str, UnfurledMediaItem], + *, + description: Optional[str] = None, + spoiler: bool = False, + id: Optional[int] = None, + ) -> None: + super().__init__() + self._media: UnfurledMediaItem = UnfurledMediaItem(media) if isinstance(media, str) else media + self.description: Optional[str] = description + self.spoiler: bool = spoiler + self.id = id + + @property + def width(self): + return 5 + + @property + def media(self) -> UnfurledMediaItem: + """:class:`discord.UnfurledMediaItem`: This thumbnail unfurled media data.""" + return self._media + + @media.setter + def media(self, value: Union[str, UnfurledMediaItem]) -> None: + if isinstance(value, str): + self._media = UnfurledMediaItem(value) + elif isinstance(value, UnfurledMediaItem): + self._media = value + else: + raise TypeError(f'expected a str or UnfurledMediaItem, not {value.__class__.__name__!r}') + + @property + def type(self) -> Literal[ComponentType.thumbnail]: + return ComponentType.thumbnail + + def _is_v2(self) -> bool: + return True + + def to_component_dict(self) -> Dict[str, Any]: + base = { + 'type': self.type.value, + 'spoiler': self.spoiler, + 'media': self.media.to_dict(), + 'description': self.description, + } + if self.id is not None: + base['id'] = self.id + return base + + @classmethod + def from_component(cls, component: ThumbnailComponent) -> Self: + return cls( + media=component.media.url, + description=component.description, + spoiler=component.spoiler, + id=component.id, + ) diff --git a/discord/ui/view.py b/discord/ui/view.py index f27b71eeb90e..0677947f6f52 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -23,7 +23,23 @@ """ from __future__ import annotations -from typing import Any, Callable, ClassVar, Coroutine, Dict, Iterator, List, Optional, Sequence, TYPE_CHECKING, Tuple, Type + +from typing import ( + Any, + Callable, + ClassVar, + Coroutine, + Dict, + Generator, + Iterator, + List, + Optional, + Sequence, + TYPE_CHECKING, + Tuple, + Type, + Union, +) from functools import partial from itertools import groupby @@ -32,6 +48,7 @@ import sys import time import os + from .item import Item, ItemCallbackType from .select import Select from .dynamic import DynamicItem @@ -41,26 +58,37 @@ _component_factory, Button as ButtonComponent, SelectMenu as SelectComponent, + SectionComponent, + TextDisplay as TextDisplayComponent, + MediaGalleryComponent, + FileComponent, + SeparatorComponent, + ThumbnailComponent, + Container as ContainerComponent, ) +from ..utils import get as _utils_get, find as _utils_find # fmt: off __all__ = ( 'View', + 'LayoutView', ) # fmt: on if TYPE_CHECKING: - from typing_extensions import Self + from typing_extensions import Self, TypeGuard import re from ..interactions import Interaction from ..message import Message - from ..types.components import Component as ComponentPayload + from ..types.components import ComponentBase as ComponentBasePayload, Component as ComponentPayload from ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload from ..state import ConnectionState from .modal import Modal + ItemLike = Union[ItemCallbackType[Any, Any], Item[Any]] + _log = logging.getLogger(__name__) @@ -69,21 +97,61 @@ def _walk_all_components(components: List[Component]) -> Iterator[Component]: for item in components: if isinstance(item, ActionRowComponent): yield from item.children + elif isinstance(item, ContainerComponent): + yield from _walk_all_components(item.children) + elif isinstance(item, SectionComponent): + yield from item.children + yield item.accessory else: yield item -def _component_to_item(component: Component) -> Item: - if isinstance(component, ButtonComponent): +def _component_to_item(component: Component, parent: Optional[Item] = None) -> Item: + if isinstance(component, ActionRowComponent): + from .action_row import ActionRow + + item = ActionRow.from_component(component) + elif isinstance(component, ButtonComponent): from .button import Button - return Button.from_component(component) - if isinstance(component, SelectComponent): + item = Button.from_component(component) + elif isinstance(component, SelectComponent): from .select import BaseSelect - return BaseSelect.from_component(component) + item = BaseSelect.from_component(component) + elif isinstance(component, SectionComponent): + from .section import Section + + item = Section.from_component(component) + elif isinstance(component, TextDisplayComponent): + from .text_display import TextDisplay + + item = TextDisplay.from_component(component) + elif isinstance(component, MediaGalleryComponent): + from .media_gallery import MediaGallery - return Item.from_component(component) + item = MediaGallery.from_component(component) + elif isinstance(component, FileComponent): + from .file import File + + item = File.from_component(component) + elif isinstance(component, SeparatorComponent): + from .separator import Separator + + item = Separator.from_component(component) + elif isinstance(component, ThumbnailComponent): + from .thumbnail import Thumbnail + + item = Thumbnail.from_component(component) + elif isinstance(component, ContainerComponent): + from .container import Container + + item = Container.from_component(component) + else: + item = Item.from_component(component) + + item._parent = parent + return item class _ViewWeights: @@ -133,73 +201,66 @@ def clear(self) -> None: class _ViewCallback: __slots__ = ('view', 'callback', 'item') - def __init__(self, callback: ItemCallbackType[Any, Any], view: View, item: Item[View]) -> None: + def __init__(self, callback: ItemCallbackType[Any, Any], view: BaseView, item: Item[BaseView]) -> None: self.callback: ItemCallbackType[Any, Any] = callback - self.view: View = view - self.item: Item[View] = item + self.view: BaseView = view + self.item: Item[BaseView] = item def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: return self.callback(self.view, interaction, self.item) -class View: - """Represents a UI view. - - This object must be inherited to create a UI within Discord. - - .. versionadded:: 2.0 - - Parameters - ----------- - timeout: Optional[:class:`float`] - Timeout in seconds from last interaction with the UI before no longer accepting input. - If ``None`` then there is no timeout. - """ - - __discord_ui_view__: ClassVar[bool] = True +class BaseView: + __discord_ui_view__: ClassVar[bool] = False __discord_ui_modal__: ClassVar[bool] = False - __view_children_items__: ClassVar[List[ItemCallbackType[Any, Any]]] = [] + __view_children_items__: ClassVar[Dict[str, ItemLike]] = {} - def __init_subclass__(cls) -> None: - super().__init_subclass__() - - children: Dict[str, ItemCallbackType[Any, Any]] = {} - for base in reversed(cls.__mro__): - for name, member in base.__dict__.items(): - if hasattr(member, '__discord_ui_model_type__'): - children[name] = member - - if len(children) > 25: - raise TypeError('View cannot have more than 25 children') - - cls.__view_children_items__ = list(children.values()) - - def _init_children(self) -> List[Item[Self]]: - children = [] - for func in self.__view_children_items__: - item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) - item.callback = _ViewCallback(func, self, item) # type: ignore - item._view = self - if isinstance(item, Select): - item.options = [option.copy() for option in item.options] - setattr(self, func.__name__, item) - children.append(item) - return children - - def __init__(self, *, timeout: Optional[float] = 180.0): + def __init__(self, *, timeout: Optional[float] = 180.0) -> None: self.__timeout = timeout self._children: List[Item[Self]] = self._init_children() - self.__weights = _ViewWeights(self._children) self.id: str = os.urandom(16).hex() self._cache_key: Optional[int] = None - self.__cancel_callback: Optional[Callable[[View], None]] = None + self.__cancel_callback: Optional[Callable[[BaseView], None]] = None self.__timeout_expiry: Optional[float] = None self.__timeout_task: Optional[asyncio.Task[None]] = None self.__stopped: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + self._total_children: int = len(tuple(self.walk_children())) + + def _is_layout(self) -> TypeGuard[LayoutView]: # type: ignore + return False def __repr__(self) -> str: return f'<{self.__class__.__name__} timeout={self.timeout} children={len(self._children)}>' + def _init_children(self) -> List[Item[Self]]: + children = [] + parents = {} + + for name, raw in self.__view_children_items__.items(): + if isinstance(raw, Item): + item = raw.copy() + setattr(self, name, item) + item._update_view(self) + parent = getattr(item, '__discord_ui_parent__', None) + if parent and parent._view is None: + parent._view = self + children.append(item) + parents[raw] = item + else: + item: Item = raw.__discord_ui_model_type__(**raw.__discord_ui_model_kwargs__) + item.callback = _ViewCallback(raw, self, item) # type: ignore + item._view = self + if isinstance(item, Select): + item.options = [option.copy() for option in item.options] + setattr(self, raw.__name__, item) + parent = getattr(raw, '__discord_ui_parent__', None) + if parent: + parents.get(parent, parent)._children.append(item) + continue + children.append(item) + + return children + async def __timeout_task_impl(self) -> None: while True: # Guard just in case someone changes the value of the timeout at runtime @@ -218,29 +279,16 @@ async def __timeout_task_impl(self) -> None: await asyncio.sleep(self.__timeout_expiry - now) def is_dispatchable(self) -> bool: - # this is used by webhooks to check whether a view requires a state attached - # or not, this simply is, whether a view has a component other than a url button - return any(item.is_dispatchable() for item in self.children) - - def to_components(self) -> List[Dict[str, Any]]: - def key(item: Item) -> int: - return item._rendered_row or 0 - - children = sorted(self._children, key=key) - components: List[Dict[str, Any]] = [] - for _, group in groupby(children, key=key): - children = [item.to_component_dict() for item in group] - if not children: - continue + # checks whether any interactable items (buttons or selects) are present + # in this view, and check whether this requires a state attached in case + # of webhooks and if the view should be stored in the view store + return any(item.is_dispatchable() for item in self.walk_children()) - components.append( - { - 'type': 1, - 'components': children, - } - ) + def has_components_v2(self) -> bool: + return any(c._is_v2() for c in self.children) - return components + def to_components(self) -> List[Dict[str, Any]]: + return NotImplemented def _refresh_timeout(self) -> None: if self.__timeout: @@ -271,13 +319,17 @@ def children(self) -> List[Item[Self]]: return self._children.copy() @classmethod - def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View: + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> Union[View, LayoutView]: """Converts a message's components into a :class:`View`. The :attr:`.Message.components` of a message are read-only and separate types from those in the ``discord.ui`` namespace. In order to modify and edit message components they must be - converted into a :class:`View` first. + converted into a :class:`View` or :class:`LayoutView` first. + + If the message has any v2 components, then you must use + :class:`LayoutView` in order for them to be converted into + their respective items. :class:`View` does not support v2 components. Parameters ----------- @@ -287,24 +339,43 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) The timeout of the converted view. Returns - -------- - :class:`View` - The converted view. This always returns a :class:`View` and not - one of its subclasses. + ------- + Union[:class:`View`, :class:`LayoutView`] + The converted view. This will always return one of :class:`View` or + :class:`LayoutView`, and not one of its subclasses. """ - view = View(timeout=timeout) + + if issubclass(cls, View): + view_cls = View + elif issubclass(cls, LayoutView): + view_cls = LayoutView + else: + raise TypeError('unreachable exception') + + view = view_cls(timeout=timeout) row = 0 + for component in message.components: - if isinstance(component, ActionRowComponent): + if not view._is_layout() and isinstance(component, ActionRowComponent): for child in component.children: item = _component_to_item(child) item.row = row + # this error should never be raised, because ActionRows can only + # contain items that View accepts, but check anyways + if item._is_v2(): + raise ValueError(f'{item.__class__.__name__} cannot be added to {view.__class__.__name__}') view.add_item(item) - row += 1 - else: - item = _component_to_item(component) - item.row = row - view.add_item(item) + row += 1 + continue + + item = _component_to_item(component) + item.row = row + + if item._is_v2() and not view._is_layout(): + raise ValueError(f'{item.__class__.__name__} cannot be added to {view.__class__.__name__}') + + view.add_item(item) + row += 1 return view @@ -324,19 +395,25 @@ def add_item(self, item: Item[Any]) -> Self: TypeError An :class:`Item` was not passed. ValueError - Maximum number of children has been exceeded (25) - or the row the item is trying to be added to is full. + Maximum number of children has been exceeded, the + row the item is trying to be added to is full or the item + you tried to add is not allowed in this View. """ - if len(self._children) >= 25: - raise ValueError('maximum number of children exceeded') - if not isinstance(item, Item): raise TypeError(f'expected Item not {item.__class__.__name__}') + if item._is_v2() and not self._is_layout(): + raise ValueError('v2 items cannot be added to this view') + + item._update_view(self) + added = 1 - self.__weights.add_item(item) + if item._has_children(): + added += len(tuple(item.walk_children())) # type: ignore - item._view = self + if self._is_layout() and self._total_children + added > 40: + raise ValueError('maximum number of children exceeded') + self._total_children += added self._children.append(item) return self @@ -357,7 +434,15 @@ def remove_item(self, item: Item[Any]) -> Self: except ValueError: pass else: - self.__weights.remove_item(item) + removed = 1 + if item._has_children(): + removed += len(tuple(item.walk_children())) # type: ignore + + if self._total_children - removed < 0: + self._total_children = 0 + else: + self._total_children -= removed + return self def clear_items(self) -> Self: @@ -367,9 +452,31 @@ def clear_items(self) -> Self: chaining. """ self._children.clear() - self.__weights.clear() + self._total_children = 0 return self + def find_item(self, id: int, /) -> Optional[Item[Self]]: + """Gets an item with :attr:`Item.id` set as ``id``, or ``None`` if + not found. + + .. warning:: + + This is **not the same** as ``custom_id``. + + .. versionadded:: 2.6 + + Parameters + ---------- + id: :class:`int` + The ID of the component. + + Returns + ------- + Optional[:class:`Item`] + The item found, or ``None``. + """ + return _utils_get(self.walk_children(), id=id) + async def interaction_check(self, interaction: Interaction, /) -> bool: """|coro| @@ -428,7 +535,7 @@ async def _scheduled_task(self, item: Item, interaction: Interaction): try: item._refresh_state(interaction, interaction.data) # type: ignore - allow = await item.interaction_check(interaction) and await self.interaction_check(interaction) + allow = await item._run_checks(interaction) and await self.interaction_check(interaction) if not allow: return @@ -440,7 +547,7 @@ async def _scheduled_task(self, item: Item, interaction: Interaction): return await self.on_error(interaction, e, item) def _start_listening_from_store(self, store: ViewStore) -> None: - self.__cancel_callback = partial(store.remove_view) + self.__cancel_callback = partial(store.remove_view) # type: ignore if self.timeout: if self.__timeout_task is not None: self.__timeout_task.cancel() @@ -469,7 +576,7 @@ def _refresh(self, components: List[Component]) -> None: # fmt: off old_state: Dict[str, Item[Any]] = { item.custom_id: item # type: ignore - for item in self._children + for item in self.walk_children() if item.is_dispatchable() } # fmt: on @@ -536,13 +643,193 @@ async def wait(self) -> bool: """ return await self.__stopped + def walk_children(self) -> Generator[Item[Any], None, None]: + """An iterator that recursively walks through all the children of this view + and its children, if applicable. + + Yields + ------ + :class:`Item` + An item in the view. + """ + + for child in self.children: + yield child + + if child._has_children(): + yield from child.walk_children() # type: ignore + + +class View(BaseView): + """Represents a UI view. + + This object must be inherited to create a UI within Discord. + + .. versionadded:: 2.0 + + Parameters + ----------- + timeout: Optional[:class:`float`] + Timeout in seconds from last interaction with the UI before no longer accepting input. + If ``None`` then there is no timeout. + """ + + __discord_ui_view__: ClassVar[bool] = True + + if TYPE_CHECKING: + + @classmethod + def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> View: + ... + + @classmethod + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View: + ... + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + children: Dict[str, ItemLike] = {} + for base in reversed(cls.__mro__): + for name, member in base.__dict__.items(): + if hasattr(member, '__discord_ui_model_type__'): + children[name] = member + elif isinstance(member, Item) and member._is_v2(): + raise ValueError(f'{name} cannot be added to this View') + + if len(children) > 25: + raise TypeError('View cannot have more than 25 children') + + cls.__view_children_items__ = children + + def __init__(self, *, timeout: Optional[float] = 180.0): + super().__init__(timeout=timeout) + self.__weights = _ViewWeights(self._children) + + def to_components(self) -> List[Dict[str, Any]]: + def key(item: Item) -> int: + return item._rendered_row or 0 + + children = sorted(self._children, key=key) + components: List[Dict[str, Any]] = [] + for _, group in groupby(children, key=key): + children = [item.to_component_dict() for item in group] + if not children: + continue + + components.append( + { + 'type': 1, + 'components': children, + } + ) + + return components + + def add_item(self, item: Item[Any]) -> Self: + if len(self._children) >= 25: + raise ValueError('maximum number of children exceeded') + + super().add_item(item) + try: + self.__weights.add_item(item) + except ValueError as e: + # if the item has no space left then remove it from _children + self._children.remove(item) + raise e + + return self + + def remove_item(self, item: Item[Any]) -> Self: + try: + self._children.remove(item) + except ValueError: + pass + else: + self.__weights.remove_item(item) + return self + + def clear_items(self) -> Self: + super().clear_items() + self.__weights.clear() + return self + + +class LayoutView(BaseView): + """Represents a layout view for components. + + This object must be inherited to create a UI within Discord. + + You can find usage examples in the :resource:`repository ` + + .. versionadded:: 2.6 + + Parameters + ---------- + timeout: Optional[:class:`float`] + Timeout in seconds from last interaction with the UI before no longer accepting input. + If ``None`` then there is no timeout. + """ + + if TYPE_CHECKING: + + @classmethod + def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> LayoutView: + ... + + @classmethod + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> LayoutView: + ... + + def __init__(self, *, timeout: Optional[float] = 180.0) -> None: + super().__init__(timeout=timeout) + + if self._total_children > 40: + raise ValueError('maximum number of children exceeded (40)') + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + children: Dict[str, ItemLike] = {} + callback_children: Dict[str, ItemCallbackType[Any, Any]] = {} + + for base in reversed(cls.__mro__): + for name, member in base.__dict__.items(): + if isinstance(member, Item): + if member._parent is not None: + continue + + member._rendered_row = member._row + children[name] = member + elif hasattr(member, '__discord_ui_model_type__') and getattr(member, '__discord_ui_parent__', None): + callback_children[name] = member + + children.update(callback_children) + cls.__view_children_items__ = children + + def _is_layout(self) -> TypeGuard[LayoutView]: # type: ignore + return True + + def to_components(self): + components: List[Dict[str, Any]] = [] + for i in self._children: + components.append(i.to_component_dict()) + + return components + + def add_item(self, item: Item[Any]) -> Self: + if self._total_children >= 40: + raise ValueError('maximum number of children exceeded (40)') + super().add_item(item) + return self + class ViewStore: def __init__(self, state: ConnectionState): # entity_id: {(component_type, custom_id): Item} - self._views: Dict[Optional[int], Dict[Tuple[int, str], Item[View]]] = {} + self._views: Dict[Optional[int], Dict[Tuple[int, str], Item[BaseView]]] = {} # message_id: View - self._synced_message_views: Dict[int, View] = {} + self._synced_message_views: Dict[int, BaseView] = {} # custom_id: Modal self._modals: Dict[str, Modal] = {} # component_type is the key @@ -550,7 +837,7 @@ def __init__(self, state: ConnectionState): self._state: ConnectionState = state @property - def persistent_views(self) -> Sequence[View]: + def persistent_views(self) -> Sequence[BaseView]: # fmt: off views = { item.view.id: item.view @@ -571,7 +858,7 @@ def remove_dynamic_items(self, *items: Type[DynamicItem[Item[Any]]]) -> None: pattern = item.__discord_ui_compiled_template__ self._dynamic_items.pop(pattern, None) - def add_view(self, view: View, message_id: Optional[int] = None) -> None: + def add_view(self, view: BaseView, message_id: Optional[int] = None) -> None: view._start_listening_from_store(self) if view.__discord_ui_modal__: self._modals[view.custom_id] = view # type: ignore @@ -579,7 +866,7 @@ def add_view(self, view: View, message_id: Optional[int] = None) -> None: dispatch_info = self._views.setdefault(message_id, {}) is_fully_dynamic = True - for item in view._children: + for item in view.walk_children(): if isinstance(item, DynamicItem): pattern = item.__discord_ui_compiled_template__ self._dynamic_items[pattern] = item.__class__ @@ -621,15 +908,16 @@ async def schedule_dynamic_item_call( if interaction.message is None: return - view = View.from_message(interaction.message, timeout=None) + view_cls = View if not interaction.message.flags.components_v2 else LayoutView + view = view_cls.from_message(interaction.message, timeout=None) - try: - base_item_index, base_item = next( - (index, child) - for index, child in enumerate(view._children) - if child.type.value == component_type and getattr(child, 'custom_id', None) == custom_id - ) - except StopIteration: + base_item = _utils_find( + lambda i: i.type.value == component_type and getattr(i, 'custom_id', None) == custom_id, + view.walk_children(), + ) + + # if the item is not found then return + if not base_item: return try: @@ -638,8 +926,17 @@ async def schedule_dynamic_item_call( _log.exception('Ignoring exception in dynamic item creation for %r', factory) return - # Swap the item in the view with our new dynamic item - view._children[base_item_index] = item + # Swap the item in the view or parent with our new dynamic item + # Prioritize the item parent: + parent = base_item._parent or view + + try: + child_index = parent._children.index(base_item) # type: ignore + except ValueError: + return + else: + parent._children[child_index] = item # type: ignore + item._view = view item._rendered_row = base_item._rendered_row item._refresh_state(interaction, interaction.data) # type: ignore @@ -681,7 +978,7 @@ def dispatch_view(self, component_type: int, custom_id: str, interaction: Intera key = (component_type, custom_id) # The entity_id can either be message_id, interaction_id, or None in that priority order. - item: Optional[Item[View]] = None + item: Optional[Item[BaseView]] = None if message_id is not None: item = self._views.get(message_id, {}).get(key) @@ -733,14 +1030,14 @@ def remove_interaction_mapping(self, interaction_id: int) -> None: def is_message_tracked(self, message_id: int) -> bool: return message_id in self._synced_message_views - def remove_message_tracking(self, message_id: int) -> Optional[View]: + def remove_message_tracking(self, message_id: int) -> Optional[BaseView]: return self._synced_message_views.pop(message_id, None) - def update_from_message(self, message_id: int, data: List[ComponentPayload]) -> None: + def update_from_message(self, message_id: int, data: List[ComponentBasePayload]) -> None: components: List[Component] = [] for component_data in data: - component = _component_factory(component_data) + component = _component_factory(component_data, self._state) # type: ignore if component is not None: components.append(component) diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index dc6557e55f8b..30d0be78850c 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -71,7 +71,7 @@ from ..emoji import Emoji from ..channel import VoiceChannel from ..abc import Snowflake - from ..ui.view import View + from ..ui.view import BaseView, View, LayoutView from ..poll import Poll import datetime from ..types.webhook import ( @@ -552,7 +552,7 @@ def interaction_message_response_params( embed: Optional[Embed] = MISSING, embeds: Sequence[Embed] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING, - view: Optional[View] = MISSING, + view: Optional[BaseView] = MISSING, allowed_mentions: Optional[AllowedMentions] = MISSING, previous_allowed_mentions: Optional[AllowedMentions] = None, poll: Poll = MISSING, @@ -592,6 +592,13 @@ def interaction_message_response_params( if view is not MISSING: if view is not None: data['components'] = view.to_components() + + if view.has_components_v2(): + if flags is not MISSING: + flags.components_v2 = True + else: + flags = MessageFlags(components_v2=True) + else: data['components'] = [] @@ -802,7 +809,7 @@ async def edit( embeds: Sequence[Embed] = MISSING, embed: Optional[Embed] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING, - view: Optional[View] = MISSING, + view: Optional[BaseView] = MISSING, allowed_mentions: Optional[AllowedMentions] = None, ) -> WebhookMessage: """|coro| @@ -1598,6 +1605,46 @@ def _create_message(self, data, *, thread: Snowflake): # state is artificial return WebhookMessage(data=data, state=state, channel=channel) # type: ignore + @overload + async def send( + self, + *, + username: str = MISSING, + avatar_url: Any = MISSING, + ephemeral: bool = MISSING, + file: File = MISSING, + files: Sequence[File] = MISSING, + allowed_mentions: AllowedMentions = MISSING, + view: LayoutView, + wait: Literal[True], + thread: Snowflake = MISSING, + thread_name: str = MISSING, + suppress_embeds: bool = MISSING, + silent: bool = MISSING, + applied_tags: List[ForumTag] = MISSING, + ) -> WebhookMessage: + ... + + @overload + async def send( + self, + *, + username: str = MISSING, + avatar_url: Any = MISSING, + ephemeral: bool = MISSING, + file: File = MISSING, + files: Sequence[File] = MISSING, + allowed_mentions: AllowedMentions = MISSING, + view: LayoutView, + wait: Literal[False] = ..., + thread: Snowflake = MISSING, + thread_name: str = MISSING, + suppress_embeds: bool = MISSING, + silent: bool = MISSING, + applied_tags: List[ForumTag] = MISSING, + ) -> None: + ... + @overload async def send( self, @@ -1661,7 +1708,7 @@ async def send( embed: Embed = MISSING, embeds: Sequence[Embed] = MISSING, allowed_mentions: AllowedMentions = MISSING, - view: View = MISSING, + view: BaseView = MISSING, thread: Snowflake = MISSING, thread_name: str = MISSING, wait: bool = False, @@ -1727,7 +1774,7 @@ async def send( Controls the mentions being processed in this message. .. versionadded:: 1.4 - view: :class:`discord.ui.View` + view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`] The view to send with the message. If the webhook is partial or is not managed by the library, then you can only send URL buttons. Otherwise, you can send views with any type of components. @@ -1931,6 +1978,33 @@ async def fetch_message(self, id: int, /, *, thread: Snowflake = MISSING) -> Web ) return self._create_message(data, thread=thread) + @overload + async def edit_message( + self, + message_id: int, + *, + attachments: Sequence[Union[Attachment, File]] = ..., + view: LayoutView, + allowed_mentions: Optional[AllowedMentions] = ..., + thread: Snowflake = ..., + ) -> WebhookMessage: + ... + + @overload + async def edit_message( + self, + message_id: int, + *, + content: Optional[str] = ..., + embeds: Sequence[Embed] = ..., + embed: Optional[Embed] = ..., + attachments: Sequence[Union[Attachment, File]] = ..., + view: Optional[View] = ..., + allowed_mentions: Optional[AllowedMentions] = ..., + thread: Snowflake = ..., + ) -> WebhookMessage: + ... + async def edit_message( self, message_id: int, @@ -1939,7 +2013,7 @@ async def edit_message( embeds: Sequence[Embed] = MISSING, embed: Optional[Embed] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING, - view: Optional[View] = MISSING, + view: Optional[BaseView] = MISSING, allowed_mentions: Optional[AllowedMentions] = None, thread: Snowflake = MISSING, ) -> WebhookMessage: @@ -1978,11 +2052,17 @@ async def edit_message( allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. - view: Optional[:class:`~discord.ui.View`] + view: Optional[Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`]] The updated view to update this message with. If ``None`` is passed then the view is removed. The webhook must have state attached, similar to :meth:`send`. + .. note:: + + To update the message to add a :class:`~discord.ui.LayoutView`, you + must explicitly set the ``content``, ``embed``, ``embeds``, and + ``attachments`` parameters to either ``None`` or an empty array, as appropriate. + .. versionadded:: 2.0 thread: :class:`~discord.abc.Snowflake` The thread the webhook message belongs to. @@ -2046,7 +2126,7 @@ async def edit_message( ) message = self._create_message(data, thread=thread) - if view and not view.is_finished(): + if view and not view.is_finished() and view.is_dispatchable(): self._state.store_view(view, message_id) return message diff --git a/discord/webhook/sync.py b/discord/webhook/sync.py index 171931b12ea2..3891a9a39e73 100644 --- a/discord/webhook/sync.py +++ b/discord/webhook/sync.py @@ -66,7 +66,7 @@ from ..message import Attachment from ..abc import Snowflake from ..state import ConnectionState - from ..ui import View + from ..ui.view import BaseView, View, LayoutView from ..types.webhook import ( Webhook as WebhookPayload, ) @@ -856,6 +856,44 @@ def _create_message(self, data: MessagePayload, *, thread: Snowflake = MISSING) # state is artificial return SyncWebhookMessage(data=data, state=state, channel=channel) # type: ignore + @overload + def send( + self, + *, + username: str = MISSING, + avatar_url: Any = MISSING, + file: File = MISSING, + files: Sequence[File] = MISSING, + allowed_mentions: AllowedMentions = MISSING, + view: LayoutView, + wait: Literal[True], + thread: Snowflake = MISSING, + thread_name: str = MISSING, + suppress_embeds: bool = MISSING, + silent: bool = MISSING, + applied_tags: List[ForumTag] = MISSING, + ) -> SyncWebhookMessage: + ... + + @overload + def send( + self, + *, + username: str = MISSING, + avatar_url: Any = MISSING, + file: File = MISSING, + files: Sequence[File] = MISSING, + allowed_mentions: AllowedMentions = MISSING, + view: LayoutView, + wait: Literal[False] = ..., + thread: Snowflake = MISSING, + thread_name: str = MISSING, + suppress_embeds: bool = MISSING, + silent: bool = MISSING, + applied_tags: List[ForumTag] = MISSING, + ) -> None: + ... + @overload def send( self, @@ -876,6 +914,7 @@ def send( silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, + view: View = MISSING, ) -> SyncWebhookMessage: ... @@ -899,6 +938,7 @@ def send( silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, + view: View = MISSING, ) -> None: ... @@ -921,7 +961,7 @@ def send( silent: bool = False, applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, - view: View = MISSING, + view: BaseView = MISSING, ) -> Optional[SyncWebhookMessage]: """Sends a message using the webhook. @@ -994,8 +1034,8 @@ def send( When sending a Poll via webhook, you cannot manually end it. .. versionadded:: 2.4 - view: :class:`~discord.ui.View` - The view to send with the message. This can only have URL buttons, which donnot + view: Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`] + The view to send with the message. This can only have non-interactible items, which do not require a state to be attached to it. If you want to send a view with any component attached to it, check :meth:`Webhook.send`. @@ -1143,6 +1183,33 @@ def fetch_message(self, id: int, /, *, thread: Snowflake = MISSING) -> SyncWebho ) return self._create_message(data, thread=thread) + @overload + def edit_message( + self, + message_id: int, + *, + attachments: Sequence[Union[Attachment, File]] = ..., + view: LayoutView, + allowed_mentions: Optional[AllowedMentions] = ..., + thread: Snowflake = ..., + ) -> SyncWebhookMessage: + ... + + @overload + def edit_message( + self, + message_id: int, + *, + content: Optional[str] = ..., + embeds: Sequence[Embed] = ..., + embed: Optional[Embed] = ..., + attachments: Sequence[Union[Attachment, File]] = ..., + view: Optional[View] = ..., + allowed_mentions: Optional[AllowedMentions] = ..., + thread: Snowflake = ..., + ) -> SyncWebhookMessage: + ... + def edit_message( self, message_id: int, @@ -1151,6 +1218,7 @@ def edit_message( embeds: Sequence[Embed] = MISSING, embed: Optional[Embed] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING, + view: Optional[BaseView] = MISSING, allowed_mentions: Optional[AllowedMentions] = None, thread: Snowflake = MISSING, ) -> SyncWebhookMessage: @@ -1177,6 +1245,13 @@ def edit_message( then all attachments are removed. .. versionadded:: 2.0 + view: Optional[Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`]] + The updated view to update this message with. This can only have non-interactible items, which do not + require a state to be attached to it. If ``None`` is passed then the view is removed. + + If you want to edit a webhook message with any component attached to it, check :meth:`WebhookMessage.edit`. + + .. versionadded:: 2.6 allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. diff --git a/docs/api.rst b/docs/api.rst index 231ca0a4bdcf..43ff502dda10 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4044,6 +4044,27 @@ of :class:`enum.Enum`. Default channels and questions count towards onboarding constraints. + +.. class:: MediaItemLoadingState + + Represents a :class:`UnfurledMediaItem` load state. + + .. attribute:: unknown + + Unknown load state. + + .. attribute:: loading + + The media item is still loading. + + .. attribute:: loaded + + The media item is loaded. + + .. attribute:: not_found + + The media item was not found. + .. _discord-api-audit-logs: Audit Log Data @@ -5716,8 +5737,6 @@ PollAnswer .. autoclass:: PollAnswer() :members: -.. _discord_api_data: - MessageSnapshot ~~~~~~~~~~~~~~~~~ @@ -5742,6 +5761,16 @@ PrimaryGuild .. autoclass:: PrimaryGuild() :members: +CallMessage +~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: CallMessage + +.. autoclass:: CallMessage() + :members: + +.. _discord_api_data: + Data Classes -------------- @@ -6053,12 +6082,21 @@ PollMedia .. autoclass:: PollMedia :members: -CallMessage -~~~~~~~~~~~~~~~~~~~ +UnfurledMediaItem +~~~~~~~~~~~~~~~~~ -.. attributetable:: CallMessage +.. attributetable:: UnfurledMediaItem -.. autoclass:: CallMessage() +.. autoclass:: UnfurledMediaItem + :members: + + +MediaGalleryItem +~~~~~~~~~~~~~~~~ + +.. attributetable:: MediaGalleryItem + +.. autoclass:: MediaGalleryItem :members: diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index 294a3b13a781..40741768dc60 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -113,6 +113,77 @@ TextInput :members: :inherited-members: + +SectionComponent +~~~~~~~~~~~~~~~~ + +.. attributetable:: SectionComponent + +.. autoclass:: SectionComponent() + :members: + :inherited-members: + + +ThumbnailComponent +~~~~~~~~~~~~~~~~~~ + +.. attributetable:: ThumbnailComponent + +.. autoclass:: ThumbnailComponent() + :members: + :inherited-members: + + +TextDisplay +~~~~~~~~~~~ + +.. attributetable:: TextDisplay + +.. autoclass:: TextDisplay() + :members: + :inherited-members: + + +MediaGalleryComponent +~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: MediaGalleryComponent + +.. autoclass:: MediaGalleryComponent() + :members: + :inherited-members: + + +FileComponent +~~~~~~~~~~~~~ + +.. attributetable:: FileComponent + +.. autoclass:: FileComponent() + :members: + :inherited-members: + + +SeparatorComponent +~~~~~~~~~~~~~~~~~~ + +.. attributetable:: SeparatorComponent + +.. autoclass:: SeparatorComponent() + :members: + :inherited-members: + + +Container +~~~~~~~~~ + +.. attributetable:: Container + +.. autoclass:: Container() + :members: + :inherited-members: + + AppCommand ~~~~~~~~~~~ @@ -299,7 +370,7 @@ Enumerations .. attribute:: action_row - Represents the group component which holds different components in a row. + Represents a component which holds different components in a row. .. attribute:: button @@ -333,6 +404,48 @@ Enumerations Represents a channel select component. + .. attribute:: section + + Represents a component which holds different components in a section. + + .. versionadded:: 2.6 + + .. attribute:: text_display + + Represents a text display component. + + .. versionadded:: 2.6 + + .. attribute:: thumbnail + + Represents a thumbnail component. + + .. versionadded:: 2.6 + + .. attribute:: media_gallery + + Represents a media gallery component. + + .. versionadded:: 2.6 + + .. attribute:: file + + Represents a file component. + + .. versionadded:: 2.6 + + .. attribute:: separator + + Represents a separator component. + + .. versionadded:: 2.6 + + .. attribute:: container + + Represents a component which holds different components in a container. + + .. versionadded:: 2.6 + .. class:: ButtonStyle Represents the style of the button component. @@ -467,6 +580,19 @@ Enumerations The permission is for a user. +.. class:: SeparatorSpacing + + The separator's size type. + + .. versionadded:: 2.6 + + .. attribute:: small + + A small separator. + .. attribute:: large + + A large separator. + .. _discord_ui_kit: Bot UI Kit @@ -482,6 +608,16 @@ View .. autoclass:: discord.ui.View :members: + :inherited-members: + +LayoutView +~~~~~~~~~~ + +.. attributetable:: discord.ui.LayoutView + +.. autoclass:: discord.ui.LayoutView + :members: + :inherited-members: Modal ~~~~~~ @@ -586,6 +722,86 @@ TextInput :members: :inherited-members: + +Container +~~~~~~~~~ + +.. attributetable:: discord.ui.Container + +.. autoclass:: discord.ui.Container + :members: + :inherited-members: + + +File +~~~~ + +.. attributetable:: discord.ui.File + +.. autoclass:: discord.ui.File + :members: + :inherited-members: + + +MediaGallery +~~~~~~~~~~~~ + +.. attributetable:: discord.ui.MediaGallery + +.. autoclass:: discord.ui.MediaGallery + :members: + :inherited-members: + + +Section +~~~~~~~ + +.. attributetable:: discord.ui.Section + +.. autoclass:: discord.ui.Section + :members: + :inherited-members: + + +Separator +~~~~~~~~~ + +.. attributetable:: discord.ui.Separator + +.. autoclass:: discord.ui.Separator + :members: + :inherited-members: + + +TextDisplay +~~~~~~~~~~~ + +.. attributetable:: discord.ui.TextDisplay + +.. autoclass:: discord.ui.TextDisplay + :members: + :inherited-members: + + +Thumbnail +~~~~~~~~~ + +.. attributetable:: discord.ui.Thumbnail + +.. autoclass:: discord.ui.Thumbnail + :members: + :inherited-members: + + +ActionRow +~~~~~~~~~ + +.. attributetable:: discord.ui.ActionRow + +.. autoclass:: discord.ui.ActionRow + :members: + :inherited-members: + .. _discord_app_commands: Application Commands diff --git a/examples/views/layout.py b/examples/views/layout.py new file mode 100644 index 000000000000..70effc30cd31 --- /dev/null +++ b/examples/views/layout.py @@ -0,0 +1,47 @@ +# This example requires the 'message_content' privileged intent to function. + +from discord.ext import commands + +import discord + + +class Bot(commands.Bot): + def __init__(self): + intents = discord.Intents.default() + intents.message_content = True + + super().__init__(command_prefix=commands.when_mentioned_or('$'), intents=intents) + + async def on_ready(self): + print(f'Logged in as {self.user} (ID: {self.user.id})') + print('------') + + +# Define a LayoutView, which will allow us to add v2 components to it. +class Layout(discord.ui.LayoutView): + # you can add any top-level component (ui.ActionRow, ui.Section, ui.Container, ui.File, etc.) here + + action_row = discord.ui.ActionRow() + + @action_row.button(label='Click Me!') + async def action_row_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_message('Hi!', ephemeral=True) + + container = discord.ui.Container( + discord.ui.TextDisplay( + 'Click the above button to receive a **very special** message!', + ), + accent_colour=discord.Colour.blurple(), + ) + + +bot = Bot() + + +@bot.command() +async def layout(ctx: commands.Context): + """Sends a very special message!""" + await ctx.send(view=Layout()) # sending LayoutView's does not allow for sending any content, embed(s), stickers, or poll + + +bot.run('token') From 705eb2c2a5ab504f754626ea78d290280386e6b9 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Thu, 14 Aug 2025 02:47:14 +0200 Subject: [PATCH 054/138] Update to support new pin endpoints --- discord/abc.py | 152 +++++++++++++++++++++++++++++++++++---- discord/http.py | 19 +++-- discord/message.py | 15 ++++ discord/types/message.py | 10 +++ 4 files changed, 179 insertions(+), 17 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index a56451da6a11..1da7a721c8bf 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -34,8 +34,10 @@ AsyncIterator, Callable, Dict, + Generator, Iterable, List, + Literal, Optional, TYPE_CHECKING, Protocol, @@ -61,6 +63,7 @@ from .sticker import GuildSticker, StickerItem from . import utils from .flags import InviteFlags +import warnings __all__ = ( 'Snowflake', @@ -114,6 +117,11 @@ MessageableChannel = Union[PartialMessageableChannel, GroupChannel] SnowflakeTime = Union["Snowflake", datetime] + class PinnedMessage(Message): + pinned_at: datetime + pinned: Literal[True] + + MISSING = utils.MISSING @@ -125,6 +133,26 @@ def __repr__(self) -> str: _undefined: Any = _Undefined() +class _PinsIterator: + def __init__(self, iterator: AsyncIterator[PinnedMessage]) -> None: + self.__iterator: AsyncIterator[PinnedMessage] = iterator + + def __await__(self) -> Generator[Any, None, List[PinnedMessage]]: + warnings.warn( + "`await .pins()` is deprecated; use `async for message in .pins()` instead.", + DeprecationWarning, + stacklevel=2, + ) + + async def gather() -> List[PinnedMessage]: + return [msg async for msg in self.__iterator] + + return gather().__await__() + + def __aiter__(self) -> AsyncIterator[PinnedMessage]: + return self.__iterator + + async def _single_delete_strategy(messages: Iterable[Message], *, reason: Optional[str] = None): for m in messages: try: @@ -1754,17 +1782,119 @@ async def fetch_message(self, id: int, /) -> Message: data = await self._state.http.get_message(channel.id, id) return self._state.create_message(channel=channel, data=data) - async def pins(self) -> List[Message]: - """|coro| + async def __pins( + self, + *, + limit: Optional[int] = 50, + before: Optional[SnowflakeTime] = None, + oldest_first: bool = False, + ) -> AsyncIterator[PinnedMessage]: + channel = await self._get_channel() + state = self._state + max_limit: int = 50 + + time: Optional[str] = ( + (before if isinstance(before, datetime) else utils.snowflake_time(before.id)).isoformat() + if before is not None + else None + ) + + while True: + retrieve = max_limit if limit is None else min(limit, max_limit) + if retrieve < 1: + break + + data = await self._state.http.pins_from( + channel_id=channel.id, + limit=retrieve, + before=time, + ) + + items = data and data['items'] + if items: + if limit is not None: + limit -= len(items) + + time = items[-1]['pinned_at'] + + # Terminate loop on next iteration; there's no data left after this + if len(items) < max_limit or not data['has_more']: + limit = 0 + + if oldest_first: + items = reversed(items) + + count = 0 + for count, m in enumerate(items, start=1): + message: Message = state.create_message(channel=channel, data=m['message']) + message._pinned_at = utils.parse_time(m['pinned_at']) + yield message # pyright: ignore[reportReturnType] + + if count < max_limit: + break + + def pins( + self, + *, + limit: Optional[int] = 50, + before: Optional[SnowflakeTime] = None, + oldest_first: bool = False, + ) -> _PinsIterator: + """Retrieves an :term:`asynchronous iterator` of the pinned messages in the channel. - Retrieves all messages that are currently pinned in the channel. + You must have :attr:`~discord.Permissions.view_channel` and + :attr:`~discord.Permissions.read_message_history` in order to use this. + + .. versionchanged:: 2.6 + + Due to a change in Discord's API, this now returns a paginated iterator instead of a list. + + For backwards compatibility, you can still retrieve a list of pinned messages by + using ``await`` on the returned object. This is however deprecated. .. note:: Due to a limitation with the Discord API, the :class:`.Message` - objects returned by this method do not contain complete + object returned by this method does not contain complete :attr:`.Message.reactions` data. + Examples + --------- + + Usage :: + + counter = 0 + async for message in channel.pins(limit=250): + counter += 1 + + Flattening into a list: :: + + messages = [message async for message in channel.pins(limit=50)] + # messages is now a list of Message... + + All parameters are optional. + + Parameters + ----------- + limit: Optional[int] + The number of pinned messages to retrieve. If ``None``, it retrieves + every pinned message in the channel. Note, however, that this would + make it a slow operation. + Defaults to ``50``. + + .. versionadded:: 2.6 + before: Optional[Union[:class:`datetime.datetime`, :class:`.abc.Snowflake`]] + Retrieve pinned messages before this time or snowflake. + If a datetime is provided, it is recommended to use a UTC aware datetime. + If the datetime is naive, it is assumed to be local time. + + .. versionadded:: 2.6 + oldest_first: :class:`bool` + If set to ``True``, return messages in oldest pin->newest pin order. + Defaults to ``False``. + + .. versionadded:: 2.6 + Raises ------- ~discord.Forbidden @@ -1772,16 +1902,12 @@ async def pins(self) -> List[Message]: ~discord.HTTPException Retrieving the pinned messages failed. - Returns - -------- - List[:class:`~discord.Message`] - The messages that are currently pinned. + Yields + ------- + :class:`~discord.Message` + The pinned message with :attr:`.Message.pinned_at` set. """ - - channel = await self._get_channel() - state = self._state - data = await state.http.pins_from(channel.id) - return [state.create_message(channel=channel, data=m) for m in data] + return _PinsIterator(self.__pins(limit=limit, before=before, oldest_first=oldest_first)) async def history( self, diff --git a/discord/http.py b/discord/http.py index 800e4cab1a14..9d1b85a50359 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1047,7 +1047,7 @@ def publish_message(self, channel_id: Snowflake, message_id: Snowflake) -> Respo def pin_message(self, channel_id: Snowflake, message_id: Snowflake, reason: Optional[str] = None) -> Response[None]: r = Route( 'PUT', - '/channels/{channel_id}/pins/{message_id}', + '/channels/{channel_id}/messages/pins/{message_id}', channel_id=channel_id, message_id=message_id, ) @@ -1056,14 +1056,25 @@ def pin_message(self, channel_id: Snowflake, message_id: Snowflake, reason: Opti def unpin_message(self, channel_id: Snowflake, message_id: Snowflake, reason: Optional[str] = None) -> Response[None]: r = Route( 'DELETE', - '/channels/{channel_id}/pins/{message_id}', + '/channels/{channel_id}/messages/pins/{message_id}', channel_id=channel_id, message_id=message_id, ) return self.request(r, reason=reason) - def pins_from(self, channel_id: Snowflake) -> Response[List[message.Message]]: - return self.request(Route('GET', '/channels/{channel_id}/pins', channel_id=channel_id)) + def pins_from( + self, + channel_id: Snowflake, + limit: Optional[int] = None, + before: Optional[str] = None, + ) -> Response[message.ChannelPins]: + params = {} + if before is not None: + params['before'] = before + if limit is not None: + params['limit'] = limit + + return self.request(Route('GET', '/channels/{channel_id}/messages/pins', channel_id=channel_id), params=params) # Member management diff --git a/discord/message.py b/discord/message.py index 039ac1ba745b..d0af26124dbe 100644 --- a/discord/message.py +++ b/discord/message.py @@ -2185,6 +2185,7 @@ class Message(PartialMessage, Hashable): 'call', 'purchase_notification', 'message_snapshots', + '_pinned_at', ) if TYPE_CHECKING: @@ -2224,6 +2225,8 @@ def __init__( self.application_id: Optional[int] = utils._get_as_snowflake(data, 'application_id') self.stickers: List[StickerItem] = [StickerItem(data=d, state=state) for d in data.get('sticker_items', [])] self.message_snapshots: List[MessageSnapshot] = MessageSnapshot._from_value(state, data.get('message_snapshots')) + # Set by Messageable.pins + self._pinned_at: Optional[datetime.datetime] = None self.poll: Optional[Poll] = None try: @@ -2644,6 +2647,18 @@ def thread(self) -> Optional[Thread]: # Fall back to guild threads in case one was created after the message return self._thread or self.guild.get_thread(self.id) + @property + def pinned_at(self) -> Optional[datetime.datetime]: + """Optional[:class:`datetime.datetime`]: An aware UTC datetime object containing the time + when the message was pinned. + + .. note:: + This is only set for messages that are returned by :meth:`abc.Messageable.pins`. + + .. versionadded:: 2.6 + """ + return self._pinned_at + @property @deprecated('interaction_metadata') def interaction(self) -> Optional[MessageInteraction]: diff --git a/discord/types/message.py b/discord/types/message.py index 6c260d44dbdf..dfb251f28355 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -237,3 +237,13 @@ class AllowedMentions(TypedDict): roles: SnowflakeList users: SnowflakeList replied_user: bool + + +class MessagePin(TypedDict): + pinned_at: str + message: Message + + +class ChannelPins(TypedDict): + items: List[MessagePin] + has_more: bool From 439bd71c0d3712a2a754ab1dc166932e97541397 Mon Sep 17 00:00:00 2001 From: Michael H Date: Wed, 13 Aug 2025 20:47:41 -0400 Subject: [PATCH 055/138] Upstream change in Python The prior changes to use of return in finally (#9981) are now insufficient. Without disclosing their intent when raising the issue, this was used by them as part of justifying a SyntaxError for working code outside of the normal process for adding errors, and with it presenting to end users in a way that breaks downstream user's existing CI While making the change, I've continued to not log errors like CancellationError or TimeoutError to users here by default, as it is not an error they need to be aware of during shutdown given the limited kinds of BaseException that could raise in this context, see: #9984 for prior analysis. I've added a debug log should anyone want access to this kind of failure while debugging gateway close, but due to how asyncio shutdown happens, this is unlikely to ever log anything useful even in a library debugging context. --- discord/gateway.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/discord/gateway.py b/discord/gateway.py index a2c3da3d2d21..50606efa95f2 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -160,9 +160,11 @@ def run(self) -> None: f.result() except Exception: _log.exception('An error occurred while stopping the gateway. Ignoring.') + except BaseException as exc: + _log.debug('A BaseException was raised while stopping the gateway', exc_info=exc) finally: self.stop() - return + return data = self.get_payload() _log.debug(self.msg, self.shard_id, data['d']) From 8c1886799d1cdd862d7600e32a9d445a82c86b1a Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 13 Aug 2025 21:13:17 -0400 Subject: [PATCH 056/138] Don't recreate ItemCallbackType --- discord/ui/action_row.py | 3 +-- discord/ui/button.py | 7 ++----- discord/ui/container.py | 6 ++---- discord/ui/item.py | 7 ++++++- discord/ui/select.py | 5 +---- 5 files changed, 12 insertions(+), 16 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 31ab6d17dd17..9af19d10dbed 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -41,7 +41,7 @@ overload, ) -from .item import I, Item +from .item import Item, ContainedItemCallbackType as ItemCallbackType from .button import Button, button as _button from .select import select as _select, Select, UserSelect, RoleSelect, ChannelSelect, MentionableSelect from ..components import ActionRow as ActionRowComponent @@ -66,7 +66,6 @@ from ..components import SelectOption from ..interactions import Interaction - ItemCallbackType = Callable[['S', Interaction[Any], I], Coroutine[Any, Any, Any]] SelectCallbackDecorator = Callable[[ItemCallbackType['S', BaseSelectT]], BaseSelectT] S = TypeVar('S', bound='ActionRow', covariant=True) diff --git a/discord/ui/button.py b/discord/ui/button.py index 97dba390cf00..a2c0c117f21a 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -25,12 +25,12 @@ from __future__ import annotations import copy -from typing import Any, Callable, Coroutine, Literal, Optional, TYPE_CHECKING, Tuple, TypeVar, Union +from typing import Callable, Literal, Optional, TYPE_CHECKING, Tuple, TypeVar, Union import inspect import os -from .item import Item, I +from .item import Item, ContainedItemCallbackType as ItemCallbackType from ..enums import ButtonStyle, ComponentType from ..partial_emoji import PartialEmoji, _EmojiTag from ..components import Button as ButtonComponent @@ -46,11 +46,8 @@ from .view import BaseView from .action_row import ActionRow from ..emoji import Emoji - from ..interactions import Interaction from ..types.components import ButtonComponent as ButtonComponentPayload - ItemCallbackType = Callable[['S', Interaction[Any], I], Coroutine[Any, Any, Any]] - S = TypeVar('S', bound='Union[BaseView, ActionRow]', covariant=True) V = TypeVar('V', bound='BaseView', covariant=True) diff --git a/discord/ui/container.py b/discord/ui/container.py index a0d0a5f3211c..bbd9239891f7 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -21,13 +21,13 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations import copy from typing import ( TYPE_CHECKING, Any, - Callable, ClassVar, Coroutine, Dict, @@ -39,7 +39,7 @@ Union, ) -from .item import Item, I +from .item import Item, ContainedItemCallbackType as ItemCallbackType from .view import _component_to_item, LayoutView from ..enums import ComponentType from ..utils import get as _utils_get @@ -51,8 +51,6 @@ from ..components import Container as ContainerComponent from ..interactions import Interaction - ItemCallbackType = Callable[['S', Interaction[Any], I], Coroutine[Any, Any, Any]] - S = TypeVar('S', bound='Container', covariant=True) V = TypeVar('V', bound='LayoutView', covariant=True) diff --git a/discord/ui/item.py b/discord/ui/item.py index 97f528cdb5ba..9cbddff2cfa8 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -25,7 +25,7 @@ from __future__ import annotations import copy -from typing import Any, Callable, Coroutine, Dict, Generic, Optional, TYPE_CHECKING, Tuple, Type, TypeVar +from typing import Any, Callable, Coroutine, Dict, Generic, Optional, TYPE_CHECKING, Union, Tuple, Type, TypeVar from ..interactions import Interaction from .._types import ClientT @@ -42,10 +42,15 @@ from ..enums import ComponentType from .view import BaseView from ..components import Component + from .action_row import ActionRow + from .container import Container I = TypeVar('I', bound='Item[Any]') V = TypeVar('V', bound='BaseView', covariant=True) +ContainerType = Union['BaseView', 'ActionRow', 'Container'] +C = TypeVar('C', bound=ContainerType, covariant=True) ItemCallbackType = Callable[[V, Interaction[Any], I], Coroutine[Any, Any, Any]] +ContainedItemCallbackType = Callable[[C, Interaction[Any], I], Coroutine[Any, Any, Any]] class Item(Generic[V]): diff --git a/discord/ui/select.py b/discord/ui/select.py index 55596ec6bad9..8a8c39993d02 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -25,7 +25,6 @@ from __future__ import annotations from typing import ( Any, - Coroutine, List, Literal, Optional, @@ -43,7 +42,7 @@ import inspect import os -from .item import Item, I +from .item import Item, ContainedItemCallbackType as ItemCallbackType from ..enums import ChannelType, ComponentType, SelectDefaultValueType from ..partial_emoji import PartialEmoji from ..emoji import Emoji @@ -103,8 +102,6 @@ Thread, ] - ItemCallbackType = Callable[['S', Interaction[Any], I], Coroutine[Any, Any, Any]] - S = TypeVar('S', bound='Union[BaseView, ActionRow]', covariant=True) V = TypeVar('V', bound='BaseView', covariant=True) BaseSelectT = TypeVar('BaseSelectT', bound='BaseSelect[Any]') From 9fb74fd7a1d24b570c4fa5ce9959c3ed35b61351 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 13 Aug 2025 21:27:38 -0400 Subject: [PATCH 057/138] Remove unnecessary TypeGuard --- discord/ui/view.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 0677947f6f52..105a7b1d8c41 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -77,7 +77,7 @@ if TYPE_CHECKING: - from typing_extensions import Self, TypeGuard + from typing_extensions import Self import re from ..interactions import Interaction @@ -226,7 +226,7 @@ def __init__(self, *, timeout: Optional[float] = 180.0) -> None: self.__stopped: asyncio.Future[bool] = asyncio.get_running_loop().create_future() self._total_children: int = len(tuple(self.walk_children())) - def _is_layout(self) -> TypeGuard[LayoutView]: # type: ignore + def _is_layout(self) -> bool: return False def __repr__(self) -> str: @@ -402,6 +402,7 @@ def add_item(self, item: Item[Any]) -> Self: if not isinstance(item, Item): raise TypeError(f'expected Item not {item.__class__.__name__}') + if item._is_v2() and not self._is_layout(): raise ValueError('v2 items cannot be added to this view') @@ -413,6 +414,7 @@ def add_item(self, item: Item[Any]) -> Self: if self._is_layout() and self._total_children + added > 40: raise ValueError('maximum number of children exceeded') + self._total_children += added self._children.append(item) return self @@ -807,7 +809,7 @@ def __init_subclass__(cls) -> None: children.update(callback_children) cls.__view_children_items__ = children - def _is_layout(self) -> TypeGuard[LayoutView]: # type: ignore + def _is_layout(self) -> bool: return True def to_components(self): From 3fb627d0785885e6560f979f888603751ac9465a Mon Sep 17 00:00:00 2001 From: Rapptz Date: Thu, 14 Aug 2025 00:27:47 -0400 Subject: [PATCH 058/138] Add support for label components and select in modals --- discord/components.py | 70 ++++++++++++++++- discord/enums.py | 1 + discord/types/components.py | 14 +++- discord/types/interactions.py | 17 ++++- discord/ui/__init__.py | 1 + discord/ui/label.py | 140 ++++++++++++++++++++++++++++++++++ discord/ui/modal.py | 27 ++++++- discord/ui/select.py | 20 +++++ discord/ui/text_input.py | 18 +++-- discord/ui/view.py | 5 ++ docs/interactions/api.rst | 26 ++++++- 11 files changed, 325 insertions(+), 14 deletions(-) create mode 100644 discord/ui/label.py diff --git a/discord/components.py b/discord/components.py index 00e5db0154d3..0c35a2feae9d 100644 --- a/discord/components.py +++ b/discord/components.py @@ -70,6 +70,7 @@ ThumbnailComponent as ThumbnailComponentPayload, ContainerComponent as ContainerComponentPayload, UnfurledMediaItem as UnfurledMediaItemPayload, + LabelComponent as LabelComponentPayload, ) from .emoji import Emoji @@ -109,6 +110,7 @@ 'Container', 'TextDisplay', 'SeparatorComponent', + 'LabelComponent', ) @@ -348,6 +350,10 @@ class SelectMenu(Component): id: Optional[:class:`int`] The ID of this component. + .. versionadded:: 2.6 + required: :class:`bool` + Whether the select is required. Only applicable within modals. + .. versionadded:: 2.6 """ @@ -361,6 +367,7 @@ class SelectMenu(Component): 'disabled', 'channel_types', 'default_values', + 'required', 'id', ) @@ -372,6 +379,7 @@ def __init__(self, data: SelectMenuPayload, /) -> None: self.placeholder: Optional[str] = data.get('placeholder') self.min_values: int = data.get('min_values', 1) self.max_values: int = data.get('max_values', 1) + self.required: bool = data.get('required', False) self.options: List[SelectOption] = [SelectOption.from_dict(option) for option in data.get('options', [])] self.disabled: bool = data.get('disabled', False) self.channel_types: List[ChannelType] = [try_enum(ChannelType, t) for t in data.get('channel_types', [])] @@ -544,7 +552,7 @@ class TextInput(Component): ------------ custom_id: Optional[:class:`str`] The ID of the text input that gets received during an interaction. - label: :class:`str` + label: Optional[:class:`str`] The label to display above the text input. style: :class:`TextStyle` The style of the text input. @@ -580,7 +588,7 @@ class TextInput(Component): def __init__(self, data: TextInputPayload, /) -> None: self.style: TextStyle = try_enum(TextStyle, data['style']) - self.label: str = data['label'] + self.label: Optional[str] = data.get('label') self.custom_id: str = data['custom_id'] self.placeholder: Optional[str] = data.get('placeholder') self.value: Optional[str] = data.get('value') @@ -1309,6 +1317,62 @@ def to_dict(self) -> ContainerComponentPayload: return payload +class LabelComponent(Component): + """Represents a label component from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type for creating a label is + :class:`discord.ui.Label` not this one. + + .. versionadded:: 2.6 + + Attributes + ---------- + label: :class:`str` + The label text to display. + description: Optional[:class:`str`] + The description text to display below the label, if any. + component: :class:`Component` + The component that this label is associated with. + id: Optional[:class:`int`] + The ID of this component. + """ + + __slots__ = ( + 'label', + 'description', + 'commponent', + 'id', + ) + + __repr_info__ = ('label', 'description', 'commponent', 'id,') + + def __init__(self, data: LabelComponentPayload, state: Optional[ConnectionState]) -> None: + self.component: Component = _component_factory(data['component'], state) # type: ignore + self.label: str = data['label'] + self.id: Optional[int] = data.get('id') + self.description: Optional[str] = data.get('description') + + @property + def type(self) -> Literal[ComponentType.label]: + return ComponentType.label + + def to_dict(self) -> LabelComponentPayload: + payload: LabelComponentPayload = { + 'type': self.type.value, + 'label': self.label, + 'component': self.component.to_dict(), # type: ignore + } + if self.description: + payload['description'] = self.description + if self.id is not None: + payload['id'] = self.id + return payload + + def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] = None) -> Optional[Component]: if data['type'] == 1: return ActionRow(data) @@ -1332,3 +1396,5 @@ def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] return SeparatorComponent(data) elif data['type'] == 17: return Container(data, state) + elif data['type'] == 18: + return LabelComponent(data, state) diff --git a/discord/enums.py b/discord/enums.py index b25c221a823a..6e6242145e2a 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -677,6 +677,7 @@ class ComponentType(Enum): file = 13 separator = 14 container = 17 + label = 18 def __int__(self) -> int: return self.value diff --git a/discord/types/components.py b/discord/types/components.py index 189122baef32..bb75a918f3a5 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -30,7 +30,7 @@ from .emoji import PartialEmoji from .channel import ChannelType -ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17] +ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17, 18] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] TextStyle = Literal[1, 2] DefaultValueType = Literal['user', 'role', 'channel'] @@ -110,7 +110,7 @@ class TextInput(ComponentBase): type: Literal[4] custom_id: str style: TextStyle - label: str + label: Optional[str] placeholder: NotRequired[str] value: NotRequired[str] required: NotRequired[bool] @@ -120,6 +120,7 @@ class TextInput(ComponentBase): class SelectMenu(SelectComponent): type: Literal[3, 5, 6, 7, 8] + required: NotRequired[bool] # Only for StringSelect within modals options: NotRequired[List[SelectOption]] channel_types: NotRequired[List[ChannelType]] default_values: NotRequired[List[SelectDefaultValues]] @@ -187,6 +188,13 @@ class ContainerComponent(ComponentBase): components: List[ContainerChildComponent] +class LabelComponent(ComponentBase): + type: Literal[18] + label: str + description: NotRequired[str] + component: Union[StringSelectComponent, TextInput] + + ActionRowChildComponent = Union[ButtonComponent, SelectMenu, TextInput] ContainerChildComponent = Union[ ActionRow, @@ -199,4 +207,4 @@ class ContainerComponent(ComponentBase): SeparatorComponent, ThumbnailComponent, ] -Component = Union[ActionRowChildComponent, ContainerChildComponent] +Component = Union[ActionRowChildComponent, LabelComponent, ContainerChildComponent] diff --git a/discord/types/interactions.py b/discord/types/interactions.py index dc9971a1f629..6b6e352a42fb 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -209,7 +209,13 @@ class ModalSubmitTextInputInteractionData(TypedDict): value: str -ModalSubmitComponentItemInteractionData = ModalSubmitTextInputInteractionData +class ModalSubmitStringSelectInteractionData(TypedDict): + type: Literal[3] + custom_id: str + values: List[str] + + +ModalSubmitComponentItemInteractionData = Union[ModalSubmitTextInputInteractionData, ModalSubmitStringSelectInteractionData] class ModalSubmitActionRowInteractionData(TypedDict): @@ -217,7 +223,14 @@ class ModalSubmitActionRowInteractionData(TypedDict): components: List[ModalSubmitComponentItemInteractionData] -ModalSubmitComponentInteractionData = Union[ModalSubmitActionRowInteractionData, ModalSubmitComponentItemInteractionData] +class ModalSubmitLabelInteractionData(TypedDict): + type: Literal[18] + component: ModalSubmitComponentItemInteractionData + + +ModalSubmitComponentInteractionData = Union[ + ModalSubmitLabelInteractionData, ModalSubmitActionRowInteractionData, ModalSubmitComponentItemInteractionData +] class ModalSubmitInteractionData(TypedDict): diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index 4d613f14faf0..2ce3655edfba 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -24,3 +24,4 @@ from .text_display import * from .thumbnail import * from .action_row import * +from .label import * diff --git a/discord/ui/label.py b/discord/ui/label.py new file mode 100644 index 000000000000..9357de425471 --- /dev/null +++ b/discord/ui/label.py @@ -0,0 +1,140 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +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. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Generator, Literal, Optional, Tuple, TypeVar + +from ..components import LabelComponent +from ..enums import ComponentType +from ..utils import MISSING +from .item import Item + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..types.components import LabelComponent as LabelComponentPayload + from .view import View + + +# fmt: off +__all__ = ( + 'Label', +) +# fmt: on + +V = TypeVar('V', bound='View', covariant=True) + + +class Label(Item[V]): + """Represents a UI label within a modal. + + .. versionadded:: 2.6 + + Parameters + ------------ + text: :class:`str` + The text to display above the input field. + Can only be up to 45 characters. + description: Optional[:class:`str`] + The description text to display right below the label text. + Can only be up to 100 characters. + component: Union[:class:`discord.ui.TextInput`, :class:`discord.ui.Select`] + The component to display below the label. + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + + Attributes + ------------ + text: :class:`str` + The text to display above the input field. + Can only be up to 45 characters. + description: Optional[:class:`str`] + The description text to display right below the label text. + Can only be up to 100 characters. + component: :class:`Item` + The component to display below the label. Currently only + supports :class:`TextInput` and :class:`Select`. + """ + + __item_repr_attributes__: Tuple[str, ...] = ( + 'text', + 'description', + 'component', + ) + + def __init__( + self, + *, + text: str, + component: Item[V], + description: Optional[str] = None, + id: Optional[int] = None, + ) -> None: + super().__init__() + self.component: Item[V] = component + self.text: str = text + self.description: Optional[str] = description + self.id = id + + @property + def width(self) -> int: + return 5 + + def _has_children(self) -> bool: + return True + + def walk_children(self) -> Generator[Item[V], None, None]: + yield self.component + + def to_component_dict(self) -> LabelComponentPayload: + payload: LabelComponentPayload = { + 'type': ComponentType.label.value, + 'label': self.text, + 'component': self.component.to_component_dict(), # type: ignore + } + if self.description: + payload['description'] = self.description + if self.id is not None: + payload['id'] = self.id + return payload + + @classmethod + def from_component(cls, component: LabelComponent) -> Self: + from .view import _component_to_item + + self = cls( + text=component.label, + component=MISSING, + description=component.description, + ) + self.component = _component_to_item(component.component, self) + return self + + @property + def type(self) -> Literal[ComponentType.label]: + return ComponentType.label + + def is_dispatchable(self) -> bool: + return False diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 630fc20f0c99..4e6d0eb22c58 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -34,6 +34,7 @@ from .._types import ClientT from .item import Item from .view import View +from .label import Label if TYPE_CHECKING: from typing_extensions import Self @@ -170,8 +171,10 @@ def _refresh(self, interaction: Interaction, components: Sequence[ModalSubmitCom for component in components: if component['type'] == 1: self._refresh(interaction, component['components']) + elif component['type'] == 18: + self._refresh(interaction, [component['component']]) else: - item = find(lambda i: i.custom_id == component['custom_id'], self._children) # type: ignore + item = find(lambda i: getattr(i, 'custom_id', None) == component['custom_id'], self.walk_children()) # type: ignore if item is None: _log.debug("Modal interaction referencing unknown item custom_id %s. Discarding", component['custom_id']) continue @@ -194,6 +197,28 @@ async def _scheduled_task(self, interaction: Interaction, components: List[Modal # In the future, maybe this will require checking if we set an error response. self.stop() + def to_components(self) -> List[Dict[str, Any]]: + def key(item: Item) -> int: + return item._rendered_row or 0 + + children = sorted(self._children, key=key) + components: List[Dict[str, Any]] = [] + for child in children: + if isinstance(child, Label): + components.append(child.to_component_dict()) # type: ignore + else: + # Every implicit child wrapped in an ActionRow in a modal + # has a single child of width 5 + # It's also deprecated to use ActionRow in modals + components.append( + { + 'type': 1, + 'components': [child.to_component_dict()], + } + ) + + return components + def _dispatch_submit( self, interaction: Interaction, components: List[ModalSubmitComponentInteractionDataPayload] ) -> None: diff --git a/discord/ui/select.py b/discord/ui/select.py index 8a8c39993d02..b2db0e10eb02 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -239,6 +239,7 @@ def __init__( min_values: Optional[int] = None, max_values: Optional[int] = None, disabled: bool = False, + required: bool = False, options: List[SelectOption] = MISSING, channel_types: List[ChannelType] = MISSING, default_values: Sequence[SelectDefaultValue] = MISSING, @@ -257,6 +258,7 @@ def __init__( min_values=min_values, max_values=max_values, disabled=disabled, + required=required, channel_types=[] if channel_types is MISSING else channel_types, options=[] if options is MISSING else options, default_values=[] if default_values is MISSING else default_values, @@ -332,6 +334,18 @@ def disabled(self) -> bool: def disabled(self, value: bool) -> None: self._underlying.disabled = bool(value) + @property + def required(self) -> bool: + """:class:`bool`: Whether the select is required or not. Only supported in modals. + + .. versionadded:: 2.6 + """ + return self._underlying.required + + @required.setter + def required(self, value: bool) -> None: + self._underlying.required = bool(value) + @property def width(self) -> int: return 5 @@ -399,6 +413,10 @@ class Select(BaseSelect[V]): Can only contain up to 25 items. disabled: :class:`bool` Whether the select is disabled or not. + required: :class:`bool` + Whether the select is required. Only applicable within modals. + + .. versionadded:: 2.6 row: Optional[:class:`int`] The relative row this select menu belongs to. A Discord component can only have 5 rows. By default, items are arranged automatically into those 5 rows. If you'd @@ -426,6 +444,7 @@ def __init__( max_values: int = 1, options: List[SelectOption] = MISSING, disabled: bool = False, + required: bool = True, row: Optional[int] = None, id: Optional[int] = None, ) -> None: @@ -436,6 +455,7 @@ def __init__( min_values=min_values, max_values=max_values, disabled=disabled, + required=required, options=options, row=row, id=id, diff --git a/discord/ui/text_input.py b/discord/ui/text_input.py index 218d7c4d090a..288e5efdcb8c 100644 --- a/discord/ui/text_input.py +++ b/discord/ui/text_input.py @@ -29,7 +29,7 @@ from ..components import TextInput as TextInputComponent from ..enums import ComponentType, TextStyle -from ..utils import MISSING +from ..utils import MISSING, deprecated from .item import Item if TYPE_CHECKING: @@ -63,9 +63,15 @@ class TextInput(Item[V]): Parameters ------------ - label: :class:`str` + label: Optional[:class:`str`] The label to display above the text input. Can only be up to 45 characters. + + .. deprecated:: 2.6 + This parameter is deprecated, use :class:`discord.ui.Label` instead. + + .. versionchanged:: 2.6 + This parameter is now optional and defaults to ``None``. custom_id: :class:`str` The ID of the text input that gets received during an interaction. If not given then one is generated for you. @@ -108,7 +114,7 @@ class TextInput(Item[V]): def __init__( self, *, - label: str, + label: Optional[str] = None, style: TextStyle = TextStyle.short, custom_id: str = MISSING, placeholder: Optional[str] = None, @@ -166,12 +172,14 @@ def value(self) -> str: return self._value or '' @property - def label(self) -> str: + @deprecated('discord.ui.Label') + def label(self) -> Optional[str]: """:class:`str`: The label of the text input.""" return self._underlying.label @label.setter - def label(self, value: str) -> None: + @deprecated('discord.ui.Label') + def label(self, value: Optional[str]) -> None: self._underlying.label = value @property diff --git a/discord/ui/view.py b/discord/ui/view.py index 105a7b1d8c41..57e036864c12 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -65,6 +65,7 @@ SeparatorComponent, ThumbnailComponent, Container as ContainerComponent, + LabelComponent, ) from ..utils import get as _utils_get, find as _utils_find @@ -147,6 +148,10 @@ def _component_to_item(component: Component, parent: Optional[Item] = None) -> I from .container import Container item = Container.from_component(component) + elif isinstance(component, LabelComponent): + from .label import Label + + item = Label.from_component(component) else: item = Item.from_component(component) diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index 40741768dc60..7e6bd53df58c 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -113,6 +113,15 @@ TextInput :members: :inherited-members: +LabelComponent +~~~~~~~~~~~~~~~~ + +.. attributetable:: LabelComponent + +.. autoclass:: LabelComponent() + :members: + :inherited-members: + SectionComponent ~~~~~~~~~~~~~~~~ @@ -425,7 +434,7 @@ Enumerations .. attribute:: media_gallery Represents a media gallery component. - + .. versionadded:: 2.6 .. attribute:: file @@ -446,6 +455,12 @@ Enumerations .. versionadded:: 2.6 + .. attribute:: label + + Represents a label container component, usually in a modal. + + .. versionadded:: 2.6 + .. class:: ButtonStyle Represents the style of the button component. @@ -742,6 +757,15 @@ File :members: :inherited-members: +Label +~~~~~~ + +.. attributetable:: discord.ui.Label + +.. autoclass:: discord.ui.Label + :members: + :inherited-members: + MediaGallery ~~~~~~~~~~~~ From 38c6407ffd642234118bfde4f97fafed883144ae Mon Sep 17 00:00:00 2001 From: Rapptz Date: Thu, 14 Aug 2025 00:39:41 -0400 Subject: [PATCH 059/138] Maintain a reference to View dispatched tasks --- discord/ui/modal.py | 6 ++++-- discord/ui/view.py | 24 +++++++++++++++++------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 4e6d0eb22c58..39f6bd9062c7 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -221,8 +221,10 @@ def key(item: Item) -> int: def _dispatch_submit( self, interaction: Interaction, components: List[ModalSubmitComponentInteractionDataPayload] - ) -> None: - asyncio.create_task(self._scheduled_task(interaction, components), name=f'discord-ui-modal-dispatch-{self.id}') + ) -> asyncio.Task[None]: + return asyncio.create_task( + self._scheduled_task(interaction, components), name=f'discord-ui-modal-dispatch-{self.id}' + ) def to_dict(self) -> Dict[str, Any]: payload = { diff --git a/discord/ui/view.py b/discord/ui/view.py index 57e036864c12..7b4fdd1c45d6 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -36,6 +36,7 @@ Optional, Sequence, TYPE_CHECKING, + Set, Tuple, Type, Union, @@ -573,11 +574,11 @@ def _dispatch_timeout(self): self.__stopped.set_result(True) asyncio.create_task(self.on_timeout(), name=f'discord-ui-view-timeout-{self.id}') - def _dispatch_item(self, item: Item, interaction: Interaction): + def _dispatch_item(self, item: Item, interaction: Interaction) -> Optional[asyncio.Task[None]]: if self.__stopped.done(): return - asyncio.create_task(self._scheduled_task(item, interaction), name=f'discord-ui-view-dispatch-{self.id}') + return asyncio.create_task(self._scheduled_task(item, interaction), name=f'discord-ui-view-dispatch-{self.id}') def _refresh(self, components: List[Component]) -> None: # fmt: off @@ -842,6 +843,7 @@ def __init__(self, state: ConnectionState): # component_type is the key self._dynamic_items: Dict[re.Pattern[str], Type[DynamicItem[Item[Any]]]] = {} self._state: ConnectionState = state + self.__tasks: Set[asyncio.Task[None]] = set() @property def persistent_views(self) -> Sequence[BaseView]: @@ -855,6 +857,10 @@ def persistent_views(self) -> Sequence[BaseView]: # fmt: on return list(views.values()) + def add_task(self, task: asyncio.Task[None]) -> None: + self.__tasks.add(task) + task.add_done_callback(self.__tasks.discard) + def add_dynamic_items(self, *items: Type[DynamicItem[Item[Any]]]) -> None: for item in items: pattern = item.__discord_ui_compiled_template__ @@ -965,9 +971,11 @@ def dispatch_dynamic_items(self, component_type: int, custom_id: str, interactio for pattern, item in self._dynamic_items.items(): match = pattern.fullmatch(custom_id) if match is not None: - asyncio.create_task( - self.schedule_dynamic_item_call(component_type, item, interaction, custom_id, match), - name=f'discord-ui-dynamic-item-{item.__name__}-{custom_id}', + self.add_task( + asyncio.create_task( + self.schedule_dynamic_item_call(component_type, item, interaction, custom_id, match), + name=f'discord-ui-dynamic-item-{item.__name__}-{custom_id}', + ) ) def dispatch_view(self, component_type: int, custom_id: str, interaction: Interaction) -> None: @@ -1014,7 +1022,9 @@ def dispatch_view(self, component_type: int, custom_id: str, interaction: Intera return # Note, at this point the View is *not* None - item.view._dispatch_item(item, interaction) # type: ignore + task = item.view._dispatch_item(item, interaction) # type: ignore + if task is not None: + self.add_task(task) def dispatch_modal( self, @@ -1027,7 +1037,7 @@ def dispatch_modal( _log.debug("Modal interaction referencing unknown custom_id %s. Discarding", custom_id) return - modal._dispatch_submit(interaction, components) + self.add_task(modal._dispatch_submit(interaction, components)) def remove_interaction_mapping(self, interaction_id: int) -> None: # This is called before re-adding the view From 773fcc83dfe0b03654b2b1b58e74948fd9497fae Mon Sep 17 00:00:00 2001 From: Soti <58138432+notSoti@users.noreply.github.com> Date: Thu, 14 Aug 2025 18:43:50 +0300 Subject: [PATCH 060/138] Remove outdated note about having to delete owned guild --- discord/guild.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index 06ce9cbb40c3..6e2a9e626ed9 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -1977,11 +1977,6 @@ async def leave(self) -> None: Leaves the guild. - .. note:: - - You cannot leave the guild that you own, you must delete it instead - via :meth:`delete`. - Raises -------- HTTPException From 4839036eea300c2f7df01bebcaea58397290d68d Mon Sep 17 00:00:00 2001 From: Sacul Date: Thu, 14 Aug 2025 23:44:41 +0800 Subject: [PATCH 061/138] Add missing guild features in types.guild --- discord/types/guild.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/discord/types/guild.py b/discord/types/guild.py index 0e328fed23f5..d491eda47b5c 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -94,6 +94,8 @@ class IncidentData(TypedDict): 'RAID_ALERTS_DISABLED', 'SOUNDBOARD', 'MORE_SOUNDBOARD', + 'GUESTS_ENABLED', + 'GUILD_TAGS', ] From 13432591c6a7b095cb8daec687667c69a3469d95 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Thu, 14 Aug 2025 18:00:09 +0200 Subject: [PATCH 062/138] Fix various docs around UI items Co-authored-by: Danny <1695103+Rapptz@users.noreply.github.com> --- discord/ui/item.py | 8 ++++---- docs/interactions/api.rst | 11 +++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/discord/ui/item.py b/discord/ui/item.py index 9cbddff2cfa8..9218d840d3fc 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -69,6 +69,7 @@ class Item(Generic[V]): - :class:`discord.ui.Separator` - :class:`discord.ui.TextDisplay` - :class:`discord.ui.Thumbnail` + - :class:`discord.ui.Label` .. versionadded:: 2.0 """ @@ -158,9 +159,7 @@ def id(self, value: Optional[int]) -> None: @property def parent(self) -> Optional[Item[V]]: - """Optional[:class:`Item`]: This item's parent. Only components that can have children - can be parents. Any item that has :class:`View` as a view will have this set to `None` - since only :class:`LayoutView` component v2 items can contain "container" like items. + """Optional[:class:`Item`]: This item's parent, if applicable. Only available on items with children. .. versionadded:: 2.6 """ @@ -211,7 +210,8 @@ async def interaction_check(self, interaction: Interaction[ClientT], /) -> bool: .. note:: If an exception occurs within the body then the check - is considered a failure and :meth:`discord.ui.View.on_error` is called. + is considered a failure and :meth:`View.on_error` + (or :meth:`LayoutView.on_error`) is called. For :class:`~discord.ui.DynamicItem` this does not call the ``on_error`` handler. diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index 7e6bd53df58c..9b5409d58b8e 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -642,6 +642,7 @@ Modal .. autoclass:: discord.ui.Modal :members: :inherited-members: + :exclude-members: from_message Item ~~~~~~~ @@ -736,6 +737,7 @@ TextInput .. autoclass:: discord.ui.TextInput :members: :inherited-members: + :exclude-members: callback Container @@ -746,6 +748,7 @@ Container .. autoclass:: discord.ui.Container :members: :inherited-members: + :exclude-members: callback File @@ -756,6 +759,7 @@ File .. autoclass:: discord.ui.File :members: :inherited-members: + :exclude-members: callback Label ~~~~~~ @@ -765,6 +769,7 @@ Label .. autoclass:: discord.ui.Label :members: :inherited-members: + :exclude-members: callback MediaGallery @@ -775,6 +780,7 @@ MediaGallery .. autoclass:: discord.ui.MediaGallery :members: :inherited-members: + :exclude-members: callback Section @@ -785,6 +791,7 @@ Section .. autoclass:: discord.ui.Section :members: :inherited-members: + :exclude-members: callback Separator @@ -795,6 +802,7 @@ Separator .. autoclass:: discord.ui.Separator :members: :inherited-members: + :exclude-members: callback TextDisplay @@ -805,6 +813,7 @@ TextDisplay .. autoclass:: discord.ui.TextDisplay :members: :inherited-members: + :exclude-members: callback Thumbnail @@ -815,6 +824,7 @@ Thumbnail .. autoclass:: discord.ui.Thumbnail :members: :inherited-members: + :exclude-members: callback ActionRow @@ -825,6 +835,7 @@ ActionRow .. autoclass:: discord.ui.ActionRow :members: :inherited-members: + :exclude-members: callback .. _discord_app_commands: From 983a9b8f9407376c6e42a24cac2f8d747ca04a7f Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Fri, 15 Aug 2025 11:36:36 +0200 Subject: [PATCH 063/138] Use Unpack where it's possible --- discord/abc.py | 7 +-- discord/app_commands/checks.py | 7 +-- discord/app_commands/commands.py | 5 +- discord/channel.py | 49 +++++++++++++++++-- discord/client.py | 27 +++++++++- discord/ext/commands/bot.py | 46 +++++++++++++---- discord/ext/commands/cog.py | 18 +++++-- discord/ext/commands/core.py | 75 +++++++++++++++++++--------- discord/ext/commands/help.py | 51 ++++++++++++++----- discord/ext/commands/hybrid.py | 63 ++++++++++++++---------- discord/flags.py | 46 +++++++++++++++-- discord/permissions.py | 84 ++++++++++++++++++++++++++++---- discord/shard.py | 8 ++- 13 files changed, 383 insertions(+), 103 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 1da7a721c8bf..979fe91f0dee 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -77,7 +77,7 @@ T = TypeVar('T', bound=VoiceProtocol) if TYPE_CHECKING: - from typing_extensions import Self + from typing_extensions import Self, Unpack from .client import Client from .user import ClientUser @@ -112,6 +112,7 @@ from .types.snowflake import ( SnowflakeList, ) + from .permissions import _PermissionOverwriteKwargs PartialMessageableChannel = Union[TextChannel, VoiceChannel, StageChannel, Thread, DMChannel, PartialMessageable] MessageableChannel = Union[PartialMessageableChannel, GroupChannel] @@ -915,7 +916,7 @@ async def set_permissions( target: Union[Member, Role], *, reason: Optional[str] = ..., - **permissions: Optional[bool], + **permissions: Unpack[_PermissionOverwriteKwargs], ) -> None: ... @@ -925,7 +926,7 @@ async def set_permissions( *, overwrite: Any = _undefined, reason: Optional[str] = None, - **permissions: Optional[bool], + **permissions: Unpack[_PermissionOverwriteKwargs], ) -> None: r"""|coro| diff --git a/discord/app_commands/checks.py b/discord/app_commands/checks.py index 5c17b951c294..3fbd677c30b0 100644 --- a/discord/app_commands/checks.py +++ b/discord/app_commands/checks.py @@ -55,8 +55,9 @@ T = TypeVar('T') if TYPE_CHECKING: - from typing_extensions import Self + from typing_extensions import Self, Unpack from ..interactions import Interaction + from ..permissions import _PermissionsKwargs CooldownFunction = Union[ Callable[[Interaction[Any]], Coroutine[Any, Any, T]], @@ -286,7 +287,7 @@ def predicate(interaction: Interaction) -> bool: return check(predicate) -def has_permissions(**perms: bool) -> Callable[[T], T]: +def has_permissions(**perms: Unpack[_PermissionsKwargs]) -> Callable[[T], T]: r"""A :func:`~discord.app_commands.check` that is added that checks if the member has all of the permissions necessary. @@ -341,7 +342,7 @@ def predicate(interaction: Interaction) -> bool: return check(predicate) -def bot_has_permissions(**perms: bool) -> Callable[[T], T]: +def bot_has_permissions(**perms: Unpack[_PermissionsKwargs]) -> Callable[[T], T]: """Similar to :func:`has_permissions` except checks if the bot itself has the permissions listed. This relies on :attr:`discord.Interaction.app_permissions`. diff --git a/discord/app_commands/commands.py b/discord/app_commands/commands.py index 2568f0027e04..e4a647f71dad 100644 --- a/discord/app_commands/commands.py +++ b/discord/app_commands/commands.py @@ -61,7 +61,7 @@ from ..utils import resolve_annotation, MISSING, is_inside_class, maybe_coroutine, async_all, _shorten, _to_kebab_case if TYPE_CHECKING: - from typing_extensions import ParamSpec, Concatenate + from typing_extensions import ParamSpec, Concatenate, Unpack from ..interactions import Interaction from ..abc import Snowflake from .namespace import Namespace @@ -73,6 +73,7 @@ # However, for type hinting purposes it's unfortunately necessary for one to # reference the other to prevent type checking errors in callbacks from discord.ext import commands + from discord.permissions import _PermissionsKwargs ErrorFunc = Callable[[Interaction, AppCommandError], Coroutine[Any, Any, None]] @@ -2840,7 +2841,7 @@ def inner(f: T) -> T: return inner -def default_permissions(perms_obj: Optional[Permissions] = None, /, **perms: bool) -> Callable[[T], T]: +def default_permissions(perms_obj: Optional[Permissions] = None, /, **perms: Unpack[_PermissionsKwargs]) -> Callable[[T], T]: r"""A decorator that sets the default permissions needed to execute this command. When this decorator is used, by default users must have these permissions to execute the command. diff --git a/discord/channel.py b/discord/channel.py index de764f6f730a..90eb85f6b92f 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -39,6 +39,7 @@ Sequence, Tuple, TypeVar, + TypedDict, Union, overload, ) @@ -85,7 +86,7 @@ ) if TYPE_CHECKING: - from typing_extensions import Self + from typing_extensions import Self, Unpack from .types.threads import ThreadArchiveDuration from .role import Role @@ -120,6 +121,44 @@ OverwriteKeyT = TypeVar('OverwriteKeyT', Role, BaseUser, Object, Union[Role, Member, Object]) + class _BaseCreateChannelOptions(TypedDict, total=False): + reason: Optional[str] + position: int + + class _CreateTextChannelOptions(_BaseCreateChannelOptions, total=False): + topic: str + slowmode_delay: int + nsfw: bool + overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] + default_auto_archive_duration: int + default_thread_slowmode_delay: int + + class _CreateVoiceChannelOptions(_BaseCreateChannelOptions, total=False): + bitrate: int + user_limit: int + rtc_region: Optional[str] + video_quality_mode: VideoQualityMode + overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] + + class _CreateStageChannelOptions(_CreateVoiceChannelOptions, total=False): + bitrate: int + user_limit: int + rtc_region: Optional[str] + video_quality_mode: VideoQualityMode + overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] + + class _CreateForumChannelOptions(_CreateTextChannelOptions, total=False): + topic: str + slowmode_delay: int + nsfw: bool + overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] + default_auto_archive_duration: int + default_thread_slowmode_delay: int + default_sort_order: ForumOrderType + default_reaction_emoji: EmojiInputType + default_layout: ForumLayoutType + available_tags: Sequence[ForumTag] + class ThreadWithMessage(NamedTuple): thread: Thread @@ -2194,7 +2233,7 @@ def forums(self) -> List[ForumChannel]: r.sort(key=lambda c: (c.position, c.id)) return r - async def create_text_channel(self, name: str, **options: Any) -> TextChannel: + async def create_text_channel(self, name: str, **options: Unpack[_CreateTextChannelOptions]) -> TextChannel: """|coro| A shortcut method to :meth:`Guild.create_text_channel` to create a :class:`TextChannel` in the category. @@ -2206,7 +2245,7 @@ async def create_text_channel(self, name: str, **options: Any) -> TextChannel: """ return await self.guild.create_text_channel(name, category=self, **options) - async def create_voice_channel(self, name: str, **options: Any) -> VoiceChannel: + async def create_voice_channel(self, name: str, **options: Unpack[_CreateVoiceChannelOptions]) -> VoiceChannel: """|coro| A shortcut method to :meth:`Guild.create_voice_channel` to create a :class:`VoiceChannel` in the category. @@ -2218,7 +2257,7 @@ async def create_voice_channel(self, name: str, **options: Any) -> VoiceChannel: """ return await self.guild.create_voice_channel(name, category=self, **options) - async def create_stage_channel(self, name: str, **options: Any) -> StageChannel: + async def create_stage_channel(self, name: str, **options: Unpack[_CreateStageChannelOptions]) -> StageChannel: """|coro| A shortcut method to :meth:`Guild.create_stage_channel` to create a :class:`StageChannel` in the category. @@ -2232,7 +2271,7 @@ async def create_stage_channel(self, name: str, **options: Any) -> StageChannel: """ return await self.guild.create_stage_channel(name, category=self, **options) - async def create_forum(self, name: str, **options: Any) -> ForumChannel: + async def create_forum(self, name: str, **options: Unpack[_CreateForumChannelOptions]) -> ForumChannel: """|coro| A shortcut method to :meth:`Guild.create_forum` to create a :class:`ForumChannel` in the category. diff --git a/discord/client.py b/discord/client.py index e80d17454864..353b159f871b 100644 --- a/discord/client.py +++ b/discord/client.py @@ -42,6 +42,7 @@ Tuple, Type, TypeVar, + TypedDict, Union, overload, ) @@ -82,7 +83,7 @@ if TYPE_CHECKING: from types import TracebackType - from typing_extensions import Self + from typing_extensions import Self, Unpack from .abc import Messageable, PrivateChannel, Snowflake, SnowflakeTime from .app_commands import Command, ContextMenu @@ -120,6 +121,28 @@ from .audit_logs import AuditLogEntry from .poll import PollAnswer from .subscription import Subscription + from .flags import MemberCacheFlags + + class _ClientOptions(TypedDict, total=False): + max_messages: int + proxy: str + proxy_auth: aiohttp.BasicAuth + shard_id: int + shard_count: int + application_id: int + member_cache_flags: MemberCacheFlags + chunk_guilds_at_startup: bool + status: Status + activity: BaseActivity + allowed_mentions: AllowedMentions + heartbeat_timeout: float + guild_ready_timeout: float + assume_unsync_clock: bool + enable_debug_events: bool + enable_raw_presences: bool + http_trace: aiohttp.TraceConfig + max_ratelimit_timeout: float + connector: aiohttp.BaseConnector # fmt: off @@ -272,7 +295,7 @@ class Client: The websocket gateway the client is currently connected to. Could be ``None``. """ - def __init__(self, *, intents: Intents, **options: Any) -> None: + def __init__(self, *, intents: Intents, **options: Unpack[_ClientOptions]) -> None: self.loop: asyncio.AbstractEventLoop = _loop # self.ws is set in the connect method self.ws: DiscordWebSocket = None # type: ignore diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 8ce872f1af7a..29b1f045e667 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -64,7 +64,7 @@ from .hybrid import hybrid_command, hybrid_group, HybridCommand, HybridGroup if TYPE_CHECKING: - from typing_extensions import Self + from typing_extensions import Self, Unpack import importlib.machinery @@ -80,12 +80,24 @@ MaybeAwaitableFunc, ) from .core import Command - from .hybrid import CommandCallback, ContextT, P + from .hybrid import CommandCallback, ContextT, P, _HybridCommandDecoratorKwargs, _HybridGroupDecoratorKwargs + from discord.client import _ClientOptions + from discord.shard import _AutoShardedClientOptions _Prefix = Union[Iterable[str], str] _PrefixCallable = MaybeAwaitableFunc[[BotT, Message], _Prefix] PrefixType = Union[_Prefix, _PrefixCallable[BotT]] + class _BotOptions(_ClientOptions, total=False): + owner_id: int + owner_ids: Collection[int] + strip_after_prefix: bool + case_insensitive: bool + + class _AutoShardedBotOptions(_AutoShardedClientOptions, _BotOptions): + ... + + __all__ = ( 'when_mentioned', 'when_mentioned_or', @@ -169,7 +181,7 @@ def __init__( allowed_contexts: app_commands.AppCommandContext = MISSING, allowed_installs: app_commands.AppInstallationType = MISSING, intents: discord.Intents, - **options: Any, + **options: Unpack[_BotOptions], ) -> None: super().__init__(intents=intents, **options) self.command_prefix: PrefixType[BotT] = command_prefix # type: ignore @@ -281,7 +293,7 @@ def hybrid_command( name: Union[str, app_commands.locale_str] = MISSING, with_app_command: bool = True, *args: Any, - **kwargs: Any, + **kwargs: Unpack[_HybridCommandDecoratorKwargs], # type: ignore # name, with_app_command ) -> Callable[[CommandCallback[Any, ContextT, P, T]], HybridCommand[Any, P, T]]: """A shortcut decorator that invokes :func:`~discord.ext.commands.hybrid_command` and adds it to the internal command list via :meth:`add_command`. @@ -293,8 +305,8 @@ def hybrid_command( """ def decorator(func: CommandCallback[Any, ContextT, P, T]): - kwargs.setdefault('parent', self) - result = hybrid_command(name=name, *args, with_app_command=with_app_command, **kwargs)(func) + kwargs.setdefault('parent', self) # type: ignore # parent is not for the user to set + result = hybrid_command(name=name, *args, with_app_command=with_app_command, **kwargs)(func) # type: ignore # name, with_app_command self.add_command(result) return result @@ -305,7 +317,7 @@ def hybrid_group( name: Union[str, app_commands.locale_str] = MISSING, with_app_command: bool = True, *args: Any, - **kwargs: Any, + **kwargs: Unpack[_HybridGroupDecoratorKwargs], # type: ignore # name, with_app_command ) -> Callable[[CommandCallback[Any, ContextT, P, T]], HybridGroup[Any, P, T]]: """A shortcut decorator that invokes :func:`~discord.ext.commands.hybrid_group` and adds it to the internal command list via :meth:`add_command`. @@ -317,8 +329,8 @@ def hybrid_group( """ def decorator(func: CommandCallback[Any, ContextT, P, T]): - kwargs.setdefault('parent', self) - result = hybrid_group(name=name, *args, with_app_command=with_app_command, **kwargs)(func) + kwargs.setdefault('parent', self) # type: ignore # parent is not for the user to set + result = hybrid_group(name=name, *args, with_app_command=with_app_command, **kwargs)(func) # type: ignore # name, with_app_command self.add_command(result) return result @@ -1527,4 +1539,18 @@ class AutoShardedBot(BotBase, discord.AutoShardedClient): .. versionadded:: 2.0 """ - pass + if TYPE_CHECKING: + + def __init__( + self, + command_prefix: PrefixType[BotT], + *, + help_command: Optional[HelpCommand] = _default, + tree_cls: Type[app_commands.CommandTree[Any]] = app_commands.CommandTree, + description: Optional[str] = None, + allowed_contexts: app_commands.AppCommandContext = MISSING, + allowed_installs: app_commands.AppInstallationType = MISSING, + intents: discord.Intents, + **kwargs: Unpack[_AutoShardedBotOptions], + ) -> None: + ... diff --git a/discord/ext/commands/cog.py b/discord/ext/commands/cog.py index 659d69ebb433..1b8a24e66be9 100644 --- a/discord/ext/commands/cog.py +++ b/discord/ext/commands/cog.py @@ -44,18 +44,30 @@ Tuple, TypeVar, Union, + TypedDict, ) from ._types import _BaseCommand, BotT if TYPE_CHECKING: - from typing_extensions import Self + from typing_extensions import Self, Unpack from discord.abc import Snowflake from discord._types import ClientT from .bot import BotBase from .context import Context - from .core import Command + from .core import Command, _CommandDecoratorKwargs + + class _CogKwargs(TypedDict, total=False): + name: str + group_name: Union[str, app_commands.locale_str] + description: str + group_description: Union[str, app_commands.locale_str] + group_nsfw: bool + group_auto_locale_strings: bool + group_extras: Dict[Any, Any] + command_attrs: _CommandDecoratorKwargs + __all__ = ( 'CogMeta', @@ -169,7 +181,7 @@ async def bar(self, ctx): __cog_app_commands__: List[Union[app_commands.Group, app_commands.Command[Any, ..., Any]]] __cog_listeners__: List[Tuple[str, str]] - def __new__(cls, *args: Any, **kwargs: Any) -> CogMeta: + def __new__(cls, *args: Any, **kwargs: Unpack[_CogKwargs]) -> CogMeta: name, bases, attrs = args if any(issubclass(base, app_commands.Group) for base in bases): raise TypeError( diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index 372fcbedfdf6..e4724508b86b 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -43,6 +43,7 @@ TypeVar, Union, overload, + TypedDict, ) import re @@ -58,10 +59,39 @@ from discord.app_commands.commands import NUMPY_DOCSTRING_ARG_REGEX if TYPE_CHECKING: - from typing_extensions import Concatenate, ParamSpec, Self + from typing_extensions import Concatenate, ParamSpec, Self, Unpack from ._types import BotT, Check, ContextT, Coro, CoroFunc, Error, Hook, UserCheck + from discord.permissions import _PermissionsKwargs + + class _CommandDecoratorKwargs(TypedDict, total=False): + enabled: bool + help: str + brief: str + usage: str + rest_is_raw: bool + aliases: List[str] + description: str + hidden: bool + checks: List[UserCheck[Context[Any]]] + cooldown: CooldownMapping[Context[Any]] + max_concurrency: MaxConcurrency + require_var_positional: bool + cooldown_after_parsing: bool + ignore_extra: bool + extras: Dict[Any, Any] + + class _CommandKwargs(_CommandDecoratorKwargs, total=False): + name: str + + class _GroupDecoratorKwargs(_CommandDecoratorKwargs, total=False): + invoke_without_command: bool + case_insensitive: bool + + class _GroupKwargs(_GroupDecoratorKwargs, total=False): + name: str + __all__ = ( 'Command', @@ -393,7 +423,7 @@ def __init__( Callable[Concatenate[Context[Any], P], Coro[T]], ], /, - **kwargs: Any, + **kwargs: Unpack[_CommandKwargs], ) -> None: if not asyncio.iscoroutinefunction(func): raise TypeError('Callback must be a coroutine.') @@ -556,7 +586,7 @@ def remove_check(self, func: UserCheck[Context[Any]], /) -> None: except ValueError: pass - def update(self, **kwargs: Any) -> None: + def update(self, **kwargs: Unpack[_CommandKwargs]) -> None: """Updates :class:`Command` instance with updated attribute. This works similarly to the :func:`~discord.ext.commands.command` decorator in terms @@ -1468,7 +1498,7 @@ def command( self: GroupMixin[CogT], name: str = ..., *args: Any, - **kwargs: Any, + **kwargs: Unpack[_CommandDecoratorKwargs], ) -> Callable[ [ Union[ @@ -1486,7 +1516,7 @@ def command( name: str = ..., cls: Type[CommandT] = ..., # type: ignore # previous overload handles case where cls is not set *args: Any, - **kwargs: Any, + **kwargs: Unpack[_CommandDecoratorKwargs], ) -> Callable[ [ Union[ @@ -1503,7 +1533,7 @@ def command( name: str = MISSING, cls: Type[Command[Any, ..., Any]] = MISSING, *args: Any, - **kwargs: Any, + **kwargs: Unpack[_CommandDecoratorKwargs], ) -> Any: """A shortcut decorator that invokes :func:`~discord.ext.commands.command` and adds it to the internal command list via :meth:`~.GroupMixin.add_command`. @@ -1515,8 +1545,7 @@ def command( """ def decorator(func): - - kwargs.setdefault('parent', self) + kwargs.setdefault('parent', self) # type: ignore # the parent kwarg is not for users to set. result = command(name=name, cls=cls, *args, **kwargs)(func) self.add_command(result) return result @@ -1528,7 +1557,7 @@ def group( self: GroupMixin[CogT], name: str = ..., *args: Any, - **kwargs: Any, + **kwargs: Unpack[_GroupDecoratorKwargs], ) -> Callable[ [ Union[ @@ -1546,7 +1575,7 @@ def group( name: str = ..., cls: Type[GroupT] = ..., # type: ignore # previous overload handles case where cls is not set *args: Any, - **kwargs: Any, + **kwargs: Unpack[_GroupDecoratorKwargs], ) -> Callable[ [ Union[ @@ -1563,7 +1592,7 @@ def group( name: str = MISSING, cls: Type[Group[Any, ..., Any]] = MISSING, *args: Any, - **kwargs: Any, + **kwargs: Unpack[_GroupDecoratorKwargs], ) -> Any: """A shortcut decorator that invokes :func:`.group` and adds it to the internal command list via :meth:`~.GroupMixin.add_command`. @@ -1575,7 +1604,7 @@ def group( """ def decorator(func): - kwargs.setdefault('parent', self) + kwargs.setdefault('parent', self) # type: ignore # the parent kwarg is not for users to set. result = group(name=name, cls=cls, *args, **kwargs)(func) self.add_command(result) return result @@ -1606,7 +1635,7 @@ class Group(GroupMixin[CogT], Command[CogT, P, T]): Defaults to ``False``. """ - def __init__(self, *args: Any, **attrs: Any) -> None: + def __init__(self, *args: Any, **attrs: Unpack[_GroupKwargs]) -> None: self.invoke_without_command: bool = attrs.pop('invoke_without_command', False) super().__init__(*args, **attrs) @@ -1728,7 +1757,7 @@ def __call__(self, func: Callable[..., Coro[T]], /) -> Any: @overload def command( name: str = ..., - **attrs: Any, + **attrs: Unpack[_CommandDecoratorKwargs], ) -> _CommandDecorator: ... @@ -1737,7 +1766,7 @@ def command( def command( name: str = ..., cls: Type[CommandT] = ..., # type: ignore # previous overload handles case where cls is not set - **attrs: Any, + **attrs: Unpack[_CommandDecoratorKwargs], ) -> Callable[ [ Union[ @@ -1753,7 +1782,7 @@ def command( def command( name: str = MISSING, cls: Type[Command[Any, ..., Any]] = MISSING, - **attrs: Any, + **attrs: Unpack[_CommandDecoratorKwargs], ) -> Any: """A decorator that transforms a function into a :class:`.Command` or if called with :func:`.group`, :class:`.Group`. @@ -1798,7 +1827,7 @@ def decorator(func): @overload def group( name: str = ..., - **attrs: Any, + **attrs: Unpack[_GroupDecoratorKwargs], ) -> _GroupDecorator: ... @@ -1807,7 +1836,7 @@ def group( def group( name: str = ..., cls: Type[GroupT] = ..., # type: ignore # previous overload handles case where cls is not set - **attrs: Any, + **attrs: Unpack[_GroupDecoratorKwargs], ) -> Callable[ [ Union[ @@ -1823,7 +1852,7 @@ def group( def group( name: str = MISSING, cls: Type[Group[Any, ..., Any]] = MISSING, - **attrs: Any, + **attrs: Unpack[_GroupDecoratorKwargs], ) -> Any: """A decorator that transforms a function into a :class:`.Group`. @@ -2165,7 +2194,7 @@ def predicate(ctx): return check(predicate) -def has_permissions(**perms: bool) -> Check[Any]: +def has_permissions(**perms: Unpack[_PermissionsKwargs]) -> Check[Any]: """A :func:`.check` that is added that checks if the member has all of the permissions necessary. @@ -2212,7 +2241,7 @@ def predicate(ctx: Context[BotT]) -> bool: return check(predicate) -def bot_has_permissions(**perms: bool) -> Check[Any]: +def bot_has_permissions(**perms: Unpack[_PermissionsKwargs]) -> Check[Any]: """Similar to :func:`.has_permissions` except checks if the bot itself has the permissions listed. @@ -2237,7 +2266,7 @@ def predicate(ctx: Context[BotT]) -> bool: return check(predicate) -def has_guild_permissions(**perms: bool) -> Check[Any]: +def has_guild_permissions(**perms: Unpack[_PermissionsKwargs]) -> Check[Any]: """Similar to :func:`.has_permissions`, but operates on guild wide permissions instead of the current channel permissions. @@ -2266,7 +2295,7 @@ def predicate(ctx: Context[BotT]) -> bool: return check(predicate) -def bot_has_guild_permissions(**perms: bool) -> Check[Any]: +def bot_has_guild_permissions(**perms: Unpack[_PermissionsKwargs]) -> Check[Any]: """Similar to :func:`.has_guild_permissions`, but checks the bot members guild permissions. diff --git a/discord/ext/commands/help.py b/discord/ext/commands/help.py index d06fbd8bf27d..90b44d16a674 100644 --- a/discord/ext/commands/help.py +++ b/discord/ext/commands/help.py @@ -42,6 +42,7 @@ Iterable, Sequence, Mapping, + TypedDict, ) import discord.utils @@ -50,7 +51,7 @@ from .errors import CommandError if TYPE_CHECKING: - from typing_extensions import Self + from typing_extensions import Self, Unpack import discord.abc @@ -58,6 +59,7 @@ from .context import Context from .cog import Cog from .parameters import Parameter + from .core import _CommandKwargs from ._types import ( UserCheck, @@ -65,6 +67,30 @@ _Bot, ) + class _HelpCommandOptions(TypedDict, total=False): + show_hidden: bool + verify_checks: bool + command_attrs: _CommandKwargs + + class _BaseHelpCommandOptions(_HelpCommandOptions, total=False): + sort_commands: bool + dm_help: bool + dm_help_threshold: int + no_category: str + paginator: Paginator + commands_heading: str + + class _DefaultHelpCommandOptions(_BaseHelpCommandOptions, total=False): + width: int + indent: int + arguments_heading: str + default_argument_description: str + show_parameter_descriptions: bool + + class _MinimalHelpCommandOptions(_BaseHelpCommandOptions, total=False): + aliases_heading: str + + __all__ = ( 'Paginator', 'HelpCommand', @@ -224,7 +250,7 @@ def _not_overridden(f: FuncT) -> FuncT: class _HelpCommandImpl(Command): - def __init__(self, inject: HelpCommand, *args: Any, **kwargs: Any) -> None: + def __init__(self, inject: HelpCommand, *args: Any, **kwargs: Unpack[_CommandKwargs]) -> None: super().__init__(inject.command_callback, *args, **kwargs) self._original: HelpCommand = inject self._injected: HelpCommand = inject @@ -299,7 +325,7 @@ def _eject_cog(self) -> None: def update(self, **kwargs: Any) -> None: cog = self.cog - self.__init__(self._original, **dict(self.__original_kwargs__, **kwargs)) + self.__init__(self._original, **dict(self.__original_kwargs__, **kwargs)) # type: ignore self.cog = cog @@ -366,10 +392,9 @@ def __new__(cls, *args: Any, **kwargs: Any) -> Self: self.__original_args__ = deepcopy(args) return self - def __init__(self, **options: Any) -> None: + def __init__(self, **options: Unpack[_HelpCommandOptions]) -> None: self.show_hidden: bool = options.pop('show_hidden', False) self.verify_checks: bool = options.pop('verify_checks', True) - self.command_attrs: Dict[str, Any] self.command_attrs = attrs = options.pop('command_attrs', {}) attrs.setdefault('name', 'help') attrs.setdefault('help', 'Shows this message') @@ -1041,7 +1066,7 @@ class DefaultHelpCommand(HelpCommand): The paginator used to paginate the help command output. """ - def __init__(self, **options: Any) -> None: + def __init__(self, **options: Unpack[_DefaultHelpCommandOptions]) -> None: self.width: int = options.pop('width', 80) self.indent: int = options.pop('indent', 2) self.sort_commands: bool = options.pop('sort_commands', True) @@ -1051,11 +1076,13 @@ def __init__(self, **options: Any) -> None: self.commands_heading: str = options.pop('commands_heading', 'Commands:') self.default_argument_description: str = options.pop('default_argument_description', 'No description given') self.no_category: str = options.pop('no_category', 'No Category') - self.paginator: Paginator = options.pop('paginator', None) self.show_parameter_descriptions: bool = options.pop('show_parameter_descriptions', True) - if self.paginator is None: + paginator = options.pop('paginator', None) + if paginator is None: self.paginator: Paginator = Paginator() + else: + self.paginator: Paginator = paginator super().__init__(**options) @@ -1334,17 +1361,19 @@ class MinimalHelpCommand(HelpCommand): The paginator used to paginate the help command output. """ - def __init__(self, **options: Any) -> None: + def __init__(self, **options: Unpack[_MinimalHelpCommandOptions]) -> None: self.sort_commands: bool = options.pop('sort_commands', True) self.commands_heading: str = options.pop('commands_heading', 'Commands') self.dm_help: bool = options.pop('dm_help', False) self.dm_help_threshold: int = options.pop('dm_help_threshold', 1000) self.aliases_heading: str = options.pop('aliases_heading', 'Aliases:') self.no_category: str = options.pop('no_category', 'No Category') - self.paginator: Paginator = options.pop('paginator', None) - if self.paginator is None: + paginator = options.pop('paginator', None) + if paginator is None: self.paginator: Paginator = Paginator(suffix=None, prefix=None) + else: + self.paginator: Paginator = paginator super().__init__(**options) diff --git a/discord/ext/commands/hybrid.py b/discord/ext/commands/hybrid.py index c584aca8fe21..70d18f5d14cd 100644 --- a/discord/ext/commands/hybrid.py +++ b/discord/ext/commands/hybrid.py @@ -24,19 +24,7 @@ from __future__ import annotations -from typing import ( - TYPE_CHECKING, - Any, - Callable, - ClassVar, - Dict, - List, - Tuple, - Type, - TypeVar, - Union, - Optional, -) +from typing import TYPE_CHECKING, Any, Callable, ClassVar, Dict, List, Tuple, Type, TypeVar, Union, Optional import discord import inspect @@ -51,7 +39,7 @@ from .view import StringView if TYPE_CHECKING: - from typing_extensions import Self, ParamSpec, Concatenate + from typing_extensions import Self, ParamSpec, Concatenate, Unpack from ._types import ContextT, Coro, BotT from .bot import Bot from .context import Context @@ -60,6 +48,29 @@ AutocompleteCallback, ChoiceT, ) + from .core import _CommandKwargs + + class _HybridCommandKwargs(_CommandKwargs, total=False): + guild_ids: list[int] + guild_only: bool + default_permissions: bool + nsfw: bool + with_app_command: bool + + class _HybridCommandDecoratorKwargs(_HybridCommandKwargs, total=False): + description: Union[str, app_commands.locale_str] + + class _HybridGroupKwargs(_HybridCommandDecoratorKwargs, total=False): + with_app_command: bool + guild_ids: list[int] + guild_only: bool + default_permissions: bool + nsfw: bool + description: str + + class _HybridGroupDecoratorKwargs(_HybridGroupKwargs, total=False): + description: Union[str, app_commands.locale_str] + fallback: Union[str, app_commands.locale_str] __all__ = ( @@ -501,7 +512,7 @@ def __init__( *, name: Union[str, app_commands.locale_str] = MISSING, description: Union[str, app_commands.locale_str] = MISSING, - **kwargs: Any, + **kwargs: Unpack[_HybridCommandKwargs], # type: ignore # name, description ) -> None: name, name_locale = (name.message, name) if isinstance(name, app_commands.locale_str) else (name, None) if name is not MISSING: @@ -621,7 +632,7 @@ def __init__( name: Union[str, app_commands.locale_str] = MISSING, description: Union[str, app_commands.locale_str] = MISSING, fallback: Optional[Union[str, app_commands.locale_str]] = None, - **attrs: Any, + **attrs: Unpack[_HybridGroupKwargs], # type: ignore # name, description ) -> None: name, name_locale = (name.message, name) if isinstance(name, app_commands.locale_str) else (name, None) if name is not MISSING: @@ -825,7 +836,7 @@ def command( name: Union[str, app_commands.locale_str] = MISSING, *args: Any, with_app_command: bool = True, - **kwargs: Any, + **kwargs: Unpack[_HybridCommandDecoratorKwargs], # type: ignore # name, with_app_command ) -> Callable[[CommandCallback[CogT, ContextT, P2, U]], HybridCommand[CogT, P2, U]]: """A shortcut decorator that invokes :func:`~discord.ext.commands.hybrid_command` and adds it to the internal command list via :meth:`add_command`. @@ -837,8 +848,8 @@ def command( """ def decorator(func: CommandCallback[CogT, ContextT, P2, U]): - kwargs.setdefault('parent', self) - result = hybrid_command(name=name, *args, with_app_command=with_app_command, **kwargs)(func) + kwargs.setdefault('parent', self) # type: ignore # parent is not for users to set + result = hybrid_command(name=name, *args, with_app_command=with_app_command, **kwargs)(func) # type: ignore # name, with_app_command self.add_command(result) return result @@ -849,7 +860,7 @@ def group( name: Union[str, app_commands.locale_str] = MISSING, *args: Any, with_app_command: bool = True, - **kwargs: Any, + **kwargs: Unpack[_HybridGroupDecoratorKwargs], # type: ignore # name, with_app_command ) -> Callable[[CommandCallback[CogT, ContextT, P2, U]], HybridGroup[CogT, P2, U]]: """A shortcut decorator that invokes :func:`~discord.ext.commands.hybrid_group` and adds it to the internal command list via :meth:`~.GroupMixin.add_command`. @@ -861,8 +872,8 @@ def group( """ def decorator(func: CommandCallback[CogT, ContextT, P2, U]): - kwargs.setdefault('parent', self) - result = hybrid_group(name=name, *args, with_app_command=with_app_command, **kwargs)(func) + kwargs.setdefault('parent', self) # type: ignore # parent is not for users to set + result = hybrid_group(name=name, *args, with_app_command=with_app_command, **kwargs)(func) # type: ignore # name, with_app_command self.add_command(result) return result @@ -873,7 +884,7 @@ def hybrid_command( name: Union[str, app_commands.locale_str] = MISSING, *, with_app_command: bool = True, - **attrs: Any, + **attrs: Unpack[_HybridCommandDecoratorKwargs], # type: ignore # name, with_app_command ) -> Callable[[CommandCallback[CogT, ContextT, P, T]], HybridCommand[CogT, P, T]]: r"""A decorator that transforms a function into a :class:`.HybridCommand`. @@ -916,7 +927,7 @@ def decorator(func: CommandCallback[CogT, ContextT, P, T]) -> HybridCommand[CogT if isinstance(func, Command): raise TypeError('Callback is already a command.') # Pyright does not allow Command[Any] to be assigned to Command[CogT] despite it being okay here - return HybridCommand(func, name=name, with_app_command=with_app_command, **attrs) # type: ignore + return HybridCommand(func, name=name, with_app_command=with_app_command, **attrs) # type: ignore # name, with_app_command return decorator @@ -925,7 +936,7 @@ def hybrid_group( name: Union[str, app_commands.locale_str] = MISSING, *, with_app_command: bool = True, - **attrs: Any, + **attrs: Unpack[_HybridGroupDecoratorKwargs], # type: ignore # name, with_app_command ) -> Callable[[CommandCallback[CogT, ContextT, P, T]], HybridGroup[CogT, P, T]]: """A decorator that transforms a function into a :class:`.HybridGroup`. @@ -949,6 +960,6 @@ def hybrid_group( def decorator(func: CommandCallback[CogT, ContextT, P, T]) -> HybridGroup[CogT, P, T]: if isinstance(func, Command): raise TypeError('Callback is already a command.') - return HybridGroup(func, name=name, with_app_command=with_app_command, **attrs) + return HybridGroup(func, name=name, with_app_command=with_app_command, **attrs) # type: ignore # name, with_app_command return decorator diff --git a/discord/flags.py b/discord/flags.py index 245ff6dc13fd..dd13ed560b6c 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -40,12 +40,48 @@ Type, TypeVar, overload, + TypedDict, ) from .enums import UserFlags if TYPE_CHECKING: - from typing_extensions import Self + from typing_extensions import Self, Unpack + + class _IntentsFlagsKwargs(TypedDict, total=False): + guilds: bool + members: bool + moderation: bool + bans: bool + emojis: bool + emojis_and_stickers: bool + expressions: bool + integrations: bool + webhooks: bool + invites: bool + voice_states: bool + presences: bool + messages: bool + guild_messages: bool + dm_messages: bool + reactions: bool + guild_reactions: bool + dm_reactions: bool + typing: bool + guild_typing: bool + dm_typing: bool + message_content: bool + guild_scheduled_events: bool + auto_moderation: bool + auto_moderation_configuration: bool + auto_moderation_execution: bool + polls: bool + guild_polls: bool + dm_polls: bool + + class _MemberCacheFlagsKwargs(TypedDict, total=False): + voice: bool + joined: bool __all__ = ( @@ -765,12 +801,12 @@ class Intents(BaseFlags): __slots__ = () - def __init__(self, value: int = 0, **kwargs: bool) -> None: + def __init__(self, value: int = 0, **kwargs: Unpack[_IntentsFlagsKwargs]) -> None: self.value: int = value - for key, value in kwargs.items(): + for key, kwvalue in kwargs.items(): if key not in self.VALID_FLAGS: raise TypeError(f'{key!r} is not a valid flag name.') - setattr(self, key, value) + setattr(self, key, kwvalue) @classmethod def all(cls: Type[Intents]) -> Intents: @@ -1426,7 +1462,7 @@ class MemberCacheFlags(BaseFlags): __slots__ = () - def __init__(self, **kwargs: bool): + def __init__(self, **kwargs: Unpack[_MemberCacheFlagsKwargs]) -> None: bits = max(self.VALID_FLAGS.values()).bit_length() self.value: int = (1 << bits) - 1 for key, value in kwargs.items(): diff --git a/discord/permissions.py b/discord/permissions.py index c234ad5f32b0..93d09db27fec 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -24,7 +24,7 @@ from __future__ import annotations -from typing import Callable, Any, ClassVar, Dict, Iterator, Set, TYPE_CHECKING, Tuple, Optional +from typing import Callable, Any, ClassVar, Dict, Iterator, Set, TYPE_CHECKING, Tuple, Optional, TypedDict, Generic, TypeVar from .flags import BaseFlags, flag_value, fill_with_flags, alias_flag_value __all__ = ( @@ -33,7 +33,73 @@ ) if TYPE_CHECKING: - from typing_extensions import Self + from typing_extensions import Self, Unpack + + BoolOrNoneT = TypeVar('BoolOrNoneT', bound=Optional[bool]) + + class _BasePermissionsKwargs(Generic[BoolOrNoneT], TypedDict, total=False): + create_instant_invite: BoolOrNoneT + kick_members: BoolOrNoneT + ban_members: BoolOrNoneT + administrator: BoolOrNoneT + manage_channels: BoolOrNoneT + manage_guild: BoolOrNoneT + add_reactions: BoolOrNoneT + view_audit_log: BoolOrNoneT + priority_speaker: BoolOrNoneT + stream: BoolOrNoneT + read_messages: BoolOrNoneT + view_channel: BoolOrNoneT + send_messages: BoolOrNoneT + send_tts_messages: BoolOrNoneT + manage_messages: BoolOrNoneT + embed_links: BoolOrNoneT + attach_files: BoolOrNoneT + read_message_history: BoolOrNoneT + mention_everyone: BoolOrNoneT + external_emojis: BoolOrNoneT + use_external_emojis: BoolOrNoneT + view_guild_insights: BoolOrNoneT + connect: BoolOrNoneT + speak: BoolOrNoneT + mute_members: BoolOrNoneT + deafen_members: BoolOrNoneT + move_members: BoolOrNoneT + use_voice_activation: BoolOrNoneT + change_nickname: BoolOrNoneT + manage_nicknames: BoolOrNoneT + manage_roles: BoolOrNoneT + manage_permissions: BoolOrNoneT + manage_webhooks: BoolOrNoneT + manage_expressions: BoolOrNoneT + manage_emojis: BoolOrNoneT + manage_emojis_and_stickers: BoolOrNoneT + use_application_commands: BoolOrNoneT + request_to_speak: BoolOrNoneT + manage_events: BoolOrNoneT + manage_threads: BoolOrNoneT + create_public_threads: BoolOrNoneT + create_private_threads: BoolOrNoneT + send_messages_in_threads: BoolOrNoneT + external_stickers: BoolOrNoneT + use_external_stickers: BoolOrNoneT + use_embedded_activities: BoolOrNoneT + moderate_members: BoolOrNoneT + use_soundboard: BoolOrNoneT + use_external_sounds: BoolOrNoneT + send_voice_messages: BoolOrNoneT + create_expressions: BoolOrNoneT + create_events: BoolOrNoneT + send_polls: BoolOrNoneT + create_polls: BoolOrNoneT + use_external_apps: BoolOrNoneT + + class _PermissionsKwargs(_BasePermissionsKwargs[bool]): + ... + + class _PermissionOverwriteKwargs(_BasePermissionsKwargs[Optional[bool]]): + ... + # A permission alias works like a regular flag but is marked # So the PermissionOverwrite knows to work with it @@ -135,18 +201,18 @@ class Permissions(BaseFlags): __slots__ = () - def __init__(self, permissions: int = 0, **kwargs: bool): + def __init__(self, permissions: int = 0, **kwargs: Unpack[_PermissionsKwargs]): if not isinstance(permissions, int): raise TypeError(f'Expected int parameter, received {permissions.__class__.__name__} instead.') self.value = permissions - for key, value in kwargs.items(): + for key, kwvalue in kwargs.items(): try: flag = self.VALID_FLAGS[key] except KeyError: raise TypeError(f'{key!r} is not a valid permission name.') from None else: - self._set_flag(flag, value) + self._set_flag(flag, kwvalue) # type: ignore # TypedDict annoyance where kwvalue is an object instead of bool def is_subset(self, other: Permissions) -> bool: """Returns ``True`` if self has the same or fewer permissions as other.""" @@ -391,7 +457,7 @@ def advanced(cls) -> Self: """ return cls(1 << 3) - def update(self, **kwargs: bool) -> None: + def update(self, **kwargs: Unpack[_PermissionsKwargs]) -> None: r"""Bulk updates this permission object. Allows you to set multiple attributes by using keyword @@ -406,7 +472,7 @@ def update(self, **kwargs: bool) -> None: for key, value in kwargs.items(): flag = self.VALID_FLAGS.get(key) if flag is not None: - self._set_flag(flag, value) + self._set_flag(flag, value) # type: ignore def handle_overwrite(self, allow: int, deny: int) -> None: # Basically this is what's happening here. @@ -918,7 +984,7 @@ class PermissionOverwrite: create_polls: Optional[bool] use_external_apps: Optional[bool] - def __init__(self, **kwargs: Optional[bool]): + def __init__(self, **kwargs: Unpack[_PermissionOverwriteKwargs]) -> None: self._values: Dict[str, Optional[bool]] = {} for key, value in kwargs.items(): @@ -980,7 +1046,7 @@ def is_empty(self) -> bool: """ return len(self._values) == 0 - def update(self, **kwargs: Optional[bool]) -> None: + def update(self, **kwargs: Unpack[_PermissionOverwriteKwargs]) -> None: r"""Bulk updates this permission overwrite object. Allows you to set multiple attributes by using keyword diff --git a/discord/shard.py b/discord/shard.py index cd10cc265244..35e46c7fa34a 100644 --- a/discord/shard.py +++ b/discord/shard.py @@ -52,6 +52,12 @@ from .activity import BaseActivity from .flags import Intents from .types.gateway import SessionStartLimit + from .client import _ClientOptions + + class _AutoShardedClientOptions(_ClientOptions, total=False): + shard_ids: List[int] + shard_connect_timeout: Optional[float] + __all__ = ( 'AutoShardedClient', @@ -365,7 +371,7 @@ class AutoShardedClient(Client): if TYPE_CHECKING: _connection: AutoShardedConnectionState - def __init__(self, *args: Any, intents: Intents, **kwargs: Any) -> None: + def __init__(self, *args: Any, intents: Intents, **kwargs: Unpack[_AutoShardedClientOptions]) -> None: kwargs.pop('shard_id', None) self.shard_ids: Optional[List[int]] = kwargs.pop('shard_ids', None) self.shard_connect_timeout: Optional[float] = kwargs.pop('shard_connect_timeout', 180.0) From 08ef9673710c394057ebadda0fe03f1d1b0138ac Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 15 Aug 2025 08:11:13 -0400 Subject: [PATCH 064/138] Typecheck when constructing MediaGalleryItem --- discord/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/components.py b/discord/components.py index 0c35a2feae9d..41c0d3788aa5 100644 --- a/discord/components.py +++ b/discord/components.py @@ -1034,7 +1034,7 @@ def __init__( description: Optional[str] = None, spoiler: bool = False, ) -> None: - self._media: UnfurledMediaItem = UnfurledMediaItem(media) if isinstance(media, str) else media + self.media = media self.description: Optional[str] = description self.spoiler: bool = spoiler self._state: Optional[ConnectionState] = None From 7e2ca02fd1e3570e8a07a3bab79eb38e025ab842 Mon Sep 17 00:00:00 2001 From: Sacul Date: Fri, 15 Aug 2025 23:38:56 +0800 Subject: [PATCH 065/138] Add total_message_sent attribute to threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Alex Nørgaard Co-authored-by: dolfies --- discord/app_commands/models.py | 6 ++++++ discord/threads.py | 6 ++++++ discord/types/channel.py | 1 + discord/types/interactions.py | 1 + discord/types/threads.py | 1 + 5 files changed, 15 insertions(+) diff --git a/discord/app_commands/models.py b/discord/app_commands/models.py index 1dd004f1dcb1..b51339c2683d 100644 --- a/discord/app_commands/models.py +++ b/discord/app_commands/models.py @@ -790,6 +790,10 @@ class AppCommandThread(Hashable): member_count: :class:`int` An approximate number of members in this thread. This caps at 50. + .. versionadded:: 2.6 + total_message_sent: :class:`int` + The total number of messages sent, including deleted messages. + .. versionadded:: 2.6 permissions: :class:`~discord.Permissions` The resolved permissions of the user who invoked @@ -830,6 +834,7 @@ class AppCommandThread(Hashable): 'member_count', 'slowmode_delay', 'last_message_id', + 'total_message_sent', '_applied_tags', '_flags', '_created_at', @@ -855,6 +860,7 @@ def __init__( self.message_count: int = int(data['message_count']) self.last_message_id: Optional[int] = _get_as_snowflake(data, 'last_message_id') self.slowmode_delay: int = data.get('rate_limit_per_user', 0) + self.total_message_sent: int = data.get('total_message_sent', 0) self._applied_tags: array.array[int] = array.array('Q', map(int, data.get('applied_tags', []))) self._flags: int = data.get('flags', 0) self._unroll_metadata(data['thread_metadata']) diff --git a/discord/threads.py b/discord/threads.py index 0c8060193f87..1700a5e61e76 100644 --- a/discord/threads.py +++ b/discord/threads.py @@ -109,6 +109,10 @@ class Thread(Messageable, Hashable): An approximate number of messages in this thread. member_count: :class:`int` An approximate number of members in this thread. This caps at 50. + total_message_sent: :class:`int` + The total number of messages sent, including deleted messages. + + .. versionadded:: 2.6 me: Optional[:class:`ThreadMember`] A thread member representing yourself, if you've joined the thread. This could not be available. @@ -152,6 +156,7 @@ class Thread(Messageable, Hashable): 'archiver_id', 'auto_archive_duration', 'archive_timestamp', + 'total_message_sent', '_created_at', '_flags', '_applied_tags', @@ -185,6 +190,7 @@ def _from_data(self, data: ThreadPayload): self.slowmode_delay: int = data.get('rate_limit_per_user', 0) self.message_count: int = data['message_count'] self.member_count: int = data['member_count'] + self.total_message_sent: int = data.get('total_message_sent', 0) self._flags: int = data.get('flags', 0) # SnowflakeList is sorted, but this would not be proper for applied tags, where order actually matters. self._applied_tags: array.array[int] = array.array('Q', map(int, data.get('applied_tags', []))) diff --git a/discord/types/channel.py b/discord/types/channel.py index 4b593e55426a..f3cc680b511a 100644 --- a/discord/types/channel.py +++ b/discord/types/channel.py @@ -126,6 +126,7 @@ class ThreadChannel(_BaseChannel): rate_limit_per_user: int message_count: int member_count: int + total_message_sent: int thread_metadata: ThreadMetadata member: NotRequired[ThreadMember] owner_id: NotRequired[Snowflake] diff --git a/discord/types/interactions.py b/discord/types/interactions.py index 6b6e352a42fb..f34166959754 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -85,6 +85,7 @@ class PartialThread(_BasePartialChannel): rate_limit_per_user: int last_message_id: NotRequired[Optional[Snowflake]] flags: NotRequired[int] + total_message_sent: int class ResolvedData(TypedDict, total=False): diff --git a/discord/types/threads.py b/discord/types/threads.py index f3b8f808c829..1dce6fac19d1 100644 --- a/discord/types/threads.py +++ b/discord/types/threads.py @@ -60,6 +60,7 @@ class Thread(TypedDict): type: ThreadType member_count: int message_count: int + total_message_sent: int rate_limit_per_user: int thread_metadata: ThreadMetadata member: NotRequired[ThreadMember] From 4f539b710f38a80a35fdb448d0e43b53d219c455 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sat, 16 Aug 2025 05:21:19 -0400 Subject: [PATCH 066/138] Fix type errors in all examples --- discord/ui/action_row.py | 2 +- examples/advanced_startup.py | 1 - examples/app_commands/basic.py | 30 ++++++++++++++++++++++++--- examples/app_commands/transformers.py | 3 +++ examples/background_task.py | 8 ++++++- examples/background_task_asyncio.py | 9 +++++++- examples/basic_bot.py | 9 +++++++- examples/basic_voice.py | 9 ++++---- examples/converters.py | 12 ++++++++--- examples/deleted.py | 3 +++ examples/edits.py | 3 +++ examples/guessing_game.py | 3 +++ examples/modals/basic.py | 3 +++ examples/new_member.py | 3 +++ examples/reaction_roles.py | 6 ++++++ examples/reply.py | 3 +++ examples/secret.py | 7 +++++++ examples/views/confirm.py | 2 ++ examples/views/counter.py | 3 +++ examples/views/dropdown.py | 3 +++ examples/views/dynamic_counter.py | 3 +++ examples/views/ephemeral.py | 3 +++ examples/views/layout.py | 3 +++ examples/views/link.py | 3 +++ examples/views/persistent.py | 3 +++ examples/views/tic_tac_toe.py | 3 +++ 26 files changed, 124 insertions(+), 16 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 9af19d10dbed..7d2fc64a94c0 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -68,7 +68,7 @@ SelectCallbackDecorator = Callable[[ItemCallbackType['S', BaseSelectT]], BaseSelectT] -S = TypeVar('S', bound='ActionRow', covariant=True) +S = TypeVar('S', bound=Union['ActionRow', 'LayoutView'], covariant=True) V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('ActionRow',) diff --git a/examples/advanced_startup.py b/examples/advanced_startup.py index 4a452188df7b..82d0a96d3820 100644 --- a/examples/advanced_startup.py +++ b/examples/advanced_startup.py @@ -5,7 +5,6 @@ import asyncio import logging import logging.handlers -import os from typing import List, Optional diff --git a/examples/app_commands/basic.py b/examples/app_commands/basic.py index f646643d0926..7dc46c657130 100644 --- a/examples/app_commands/basic.py +++ b/examples/app_commands/basic.py @@ -8,6 +8,9 @@ class MyClient(discord.Client): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + def __init__(self, *, intents: discord.Intents): super().__init__(intents=intents) # A CommandTree is a special type that holds all the application command @@ -72,10 +75,17 @@ async def send(interaction: discord.Interaction, text_to_send: str): async def joined(interaction: discord.Interaction, member: Optional[discord.Member] = None): """Says when a member joined.""" # If no member is explicitly provided then we use the command user here - member = member or interaction.user + user = member or interaction.user + + # Tell the type checker that this is a Member + assert isinstance(user, discord.Member) # The format_dt function formats the date time into a human readable representation in the official client - await interaction.response.send_message(f'{member} joined {discord.utils.format_dt(member.joined_at)}') + # Joined at can be None in very bizarre cases so just handle that as well + if user.joined_at is None: + await interaction.response.send_message(f'{user} has no join date.') + else: + await interaction.response.send_message(f'{user} joined {discord.utils.format_dt(user.joined_at)}') # A Context Menu command is an app command that can be run on a member or on a message by @@ -86,7 +96,12 @@ async def joined(interaction: discord.Interaction, member: Optional[discord.Memb @client.tree.context_menu(name='Show Join Date') async def show_join_date(interaction: discord.Interaction, member: discord.Member): # The format_dt function formats the date time into a human readable representation in the official client - await interaction.response.send_message(f'{member} joined at {discord.utils.format_dt(member.joined_at)}') + # Joined at can be None in very bizarre cases so just handle that as well + + if member.joined_at is None: + await interaction.response.send_message(f'{member} has no join date.') + else: + await interaction.response.send_message(f'{member} joined at {discord.utils.format_dt(member.joined_at)}') # This context menu command only works on messages @@ -97,9 +112,18 @@ async def report_message(interaction: discord.Interaction, message: discord.Mess f'Thanks for reporting this message by {message.author.mention} to our moderators.', ephemeral=True ) + # Make sure that we're inside a guild + if interaction.guild is None: + await interaction.response.send_message('This command can only be used in a server.', ephemeral=True) + return + # Handle report by sending it into a log channel log_channel = interaction.guild.get_channel(0) # replace with your channel id + if log_channel is None or not isinstance(log_channel, discord.abc.Messageable): + await interaction.response.send_message('Log channel not found or not messageable.', ephemeral=True) + return + embed = discord.Embed(title='Reported Message') if message.content: embed.description = message.content diff --git a/examples/app_commands/transformers.py b/examples/app_commands/transformers.py index 2fb1231c457b..a8275ad07e2d 100644 --- a/examples/app_commands/transformers.py +++ b/examples/app_commands/transformers.py @@ -12,6 +12,9 @@ class MyClient(discord.Client): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + def __init__(self): super().__init__(intents=discord.Intents.default()) self.tree = app_commands.CommandTree(self) diff --git a/examples/background_task.py b/examples/background_task.py index 657aeb34bd2c..40d543c51f80 100644 --- a/examples/background_task.py +++ b/examples/background_task.py @@ -4,6 +4,9 @@ class MyClient(discord.Client): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -21,8 +24,11 @@ async def on_ready(self): @tasks.loop(seconds=60) # task runs every 60 seconds async def my_background_task(self): channel = self.get_channel(1234567) # channel ID goes here + # Tell the type checker that this is a messageable channel + assert isinstance(channel, discord.abc.Messageable) + self.counter += 1 - await channel.send(self.counter) + await channel.send(str(self.counter)) @my_background_task.before_loop async def before_my_task(self): diff --git a/examples/background_task_asyncio.py b/examples/background_task_asyncio.py index 8e9f3ce65212..33b19bb387db 100644 --- a/examples/background_task_asyncio.py +++ b/examples/background_task_asyncio.py @@ -3,6 +3,9 @@ class MyClient(discord.Client): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -18,9 +21,13 @@ async def my_background_task(self): await self.wait_until_ready() counter = 0 channel = self.get_channel(1234567) # channel ID goes here + + # Tell the type checker that this is a messageable channel + assert isinstance(channel, discord.abc.Messageable) + while not self.is_closed(): counter += 1 - await channel.send(counter) + await channel.send(str(counter)) await asyncio.sleep(60) # task runs every 60 seconds diff --git a/examples/basic_bot.py b/examples/basic_bot.py index 6e6aee182b37..738ae291a4a5 100644 --- a/examples/basic_bot.py +++ b/examples/basic_bot.py @@ -18,6 +18,9 @@ @bot.event async def on_ready(): + # Tell the type checker that User is filled up at this point + assert bot.user is not None + print(f'Logged in as {bot.user} (ID: {bot.user.id})') print('------') @@ -57,7 +60,11 @@ async def repeat(ctx, times: int, content='repeating...'): @bot.command() async def joined(ctx, member: discord.Member): """Says when a member joined.""" - await ctx.send(f'{member.name} joined {discord.utils.format_dt(member.joined_at)}') + # Joined at can be None in very bizarre cases so just handle that as well + if member.joined_at is None: + await ctx.send(f'{member} has no join date.') + else: + await ctx.send(f'{member} joined {discord.utils.format_dt(member.joined_at)}') @bot.group() diff --git a/examples/basic_voice.py b/examples/basic_voice.py index c0759e2a0117..e21dd684b6c1 100644 --- a/examples/basic_voice.py +++ b/examples/basic_voice.py @@ -25,10 +25,6 @@ 'source_address': '0.0.0.0', # bind to ipv4 since ipv6 addresses cause issues sometimes } -ffmpeg_options = { - 'options': '-vn', -} - ytdl = youtube_dl.YoutubeDL(ytdl_format_options) @@ -51,7 +47,7 @@ async def from_url(cls, url, *, loop=None, stream=False): data = data['entries'][0] filename = data['url'] if stream else ytdl.prepare_filename(data) - return cls(discord.FFmpegPCMAudio(filename, **ffmpeg_options), data=data) + return cls(discord.FFmpegPCMAudio(filename, options='-vn'), data=data) class Music(commands.Cog): @@ -138,6 +134,9 @@ async def ensure_voice(self, ctx): @bot.event async def on_ready(): + # Tell the type checker that User is filled up at this point + assert bot.user is not None + print(f'Logged in as {bot.user} (ID: {bot.user.id})') print('------') diff --git a/examples/converters.py b/examples/converters.py index f8cae5675523..c1809692e593 100644 --- a/examples/converters.py +++ b/examples/converters.py @@ -83,8 +83,14 @@ async def convert(self, ctx: commands.Context, argument: str): raise commands.BadArgument(f'No Member or TextChannel could be converted from "{argument}"') +# Make it so the converter is friendly to type checkers +# The first parameter of typing.Annotated is the type we tell the type checker +# The second parameter is what converter the library uses +ChannelOrMember = typing.Annotated[typing.Union[discord.Member, discord.TextChannel], ChannelOrMemberConverter] + + @bot.command() -async def notify(ctx: commands.Context, target: ChannelOrMemberConverter): +async def notify(ctx: commands.Context, target: ChannelOrMember): # This command signature utilises the custom converter written above # What will happen during command invocation is that the `target` above will be passed to # the `argument` parameter of the `ChannelOrMemberConverter.convert` method and @@ -118,8 +124,8 @@ async def multiply(ctx: commands.Context, number: int, maybe: bool): # See: https://discordpy.readthedocs.io/en/latest/ext/commands/commands.html#bool if maybe is True: - return await ctx.send(number * 2) - await ctx.send(number * 5) + return await ctx.send(str(number * 2)) + await ctx.send(str(number * 5)) bot.run('token') diff --git a/examples/deleted.py b/examples/deleted.py index 97dac463158a..1ac179643fad 100644 --- a/examples/deleted.py +++ b/examples/deleted.py @@ -4,6 +4,9 @@ class MyClient(discord.Client): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + async def on_ready(self): print(f'Logged in as {self.user} (ID: {self.user.id})') print('------') diff --git a/examples/edits.py b/examples/edits.py index 5b089109cf5f..422502a6eefe 100644 --- a/examples/edits.py +++ b/examples/edits.py @@ -5,6 +5,9 @@ class MyClient(discord.Client): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + async def on_ready(self): print(f'Logged in as {self.user} (ID: {self.user.id})') print('------') diff --git a/examples/guessing_game.py b/examples/guessing_game.py index dd6c26ca92a0..ff2a0bfc89f9 100644 --- a/examples/guessing_game.py +++ b/examples/guessing_game.py @@ -6,6 +6,9 @@ class MyClient(discord.Client): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + async def on_ready(self): print(f'Logged in as {self.user} (ID: {self.user.id})') print('------') diff --git a/examples/modals/basic.py b/examples/modals/basic.py index edde8435b4f3..27215b669752 100644 --- a/examples/modals/basic.py +++ b/examples/modals/basic.py @@ -9,6 +9,9 @@ class MyClient(discord.Client): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + def __init__(self) -> None: # Just default intents and a `discord.Client` instance # We don't need a `commands.Bot` instance because we are not diff --git a/examples/new_member.py b/examples/new_member.py index 7cc84251e8a6..60fe94b208cc 100644 --- a/examples/new_member.py +++ b/examples/new_member.py @@ -4,6 +4,9 @@ class MyClient(discord.Client): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + async def on_ready(self): print(f'Logged in as {self.user} (ID: {self.user.id})') print('------') diff --git a/examples/reaction_roles.py b/examples/reaction_roles.py index 99c4d17b692b..2ab7e9f65273 100644 --- a/examples/reaction_roles.py +++ b/examples/reaction_roles.py @@ -4,6 +4,9 @@ class MyClient(discord.Client): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -36,6 +39,9 @@ async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): # Make sure the role still exists and is valid. return + # Tell the type checker that RawReactionActionEvent.member is not none during REACTION_ADD + assert payload.member is not None + try: # Finally, add the role. await payload.member.add_roles(role) diff --git a/examples/reply.py b/examples/reply.py index f2ccb4a7ce86..94154894a654 100644 --- a/examples/reply.py +++ b/examples/reply.py @@ -4,6 +4,9 @@ class MyClient(discord.Client): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + async def on_ready(self): print(f'Logged in as {self.user} (ID: {self.user.id})') print('------') diff --git a/examples/secret.py b/examples/secret.py index 434b5f0aaec6..1d7e5587740b 100644 --- a/examples/secret.py +++ b/examples/secret.py @@ -52,6 +52,9 @@ async def text(ctx: commands.Context, name: str, *objects: typing.Union[discord. overwrites = create_overwrites(ctx, *objects) + # Tell the type checker that our Guild is not None + assert ctx.guild is not None + await ctx.guild.create_text_channel( name, overwrites=overwrites, @@ -69,6 +72,8 @@ async def voice(ctx: commands.Context, name: str, *objects: typing.Union[discord overwrites = create_overwrites(ctx, *objects) + assert ctx.guild is not None + await ctx.guild.create_voice_channel( name, overwrites=overwrites, @@ -86,6 +91,8 @@ async def emoji(ctx: commands.Context, emoji: discord.PartialEmoji, *roles: disc # fetch the emoji asset and read it as bytes. emoji_bytes = await emoji.read() + assert ctx.guild is not None + # the key parameter here is `roles`, which controls # what roles are able to use the emoji. await ctx.guild.create_custom_emoji( diff --git a/examples/views/confirm.py b/examples/views/confirm.py index 5500dc2a6a50..7695aac70b47 100644 --- a/examples/views/confirm.py +++ b/examples/views/confirm.py @@ -6,6 +6,8 @@ class Bot(commands.Bot): + user: discord.ClientUser + def __init__(self): intents = discord.Intents.default() intents.message_content = True diff --git a/examples/views/counter.py b/examples/views/counter.py index e3cd40e818bb..6d18c3be5633 100644 --- a/examples/views/counter.py +++ b/examples/views/counter.py @@ -6,6 +6,9 @@ class CounterBot(commands.Bot): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + def __init__(self): intents = discord.Intents.default() intents.message_content = True diff --git a/examples/views/dropdown.py b/examples/views/dropdown.py index eb055eebd1c1..d02921b0a353 100644 --- a/examples/views/dropdown.py +++ b/examples/views/dropdown.py @@ -38,6 +38,9 @@ def __init__(self): class Bot(commands.Bot): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + def __init__(self): intents = discord.Intents.default() intents.message_content = True diff --git a/examples/views/dynamic_counter.py b/examples/views/dynamic_counter.py index cfb02ee5ddda..848b569a6190 100644 --- a/examples/views/dynamic_counter.py +++ b/examples/views/dynamic_counter.py @@ -70,6 +70,9 @@ async def callback(self, interaction: discord.Interaction) -> None: class DynamicCounterBot(commands.Bot): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + def __init__(self): intents = discord.Intents.default() super().__init__(command_prefix=commands.when_mentioned, intents=intents) diff --git a/examples/views/ephemeral.py b/examples/views/ephemeral.py index 5a5fbffab5d2..3864e945d4a0 100644 --- a/examples/views/ephemeral.py +++ b/examples/views/ephemeral.py @@ -6,6 +6,9 @@ class EphemeralCounterBot(commands.Bot): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + def __init__(self): intents = discord.Intents.default() intents.message_content = True diff --git a/examples/views/layout.py b/examples/views/layout.py index 70effc30cd31..a5d1613296e0 100644 --- a/examples/views/layout.py +++ b/examples/views/layout.py @@ -6,6 +6,9 @@ class Bot(commands.Bot): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + def __init__(self): intents = discord.Intents.default() intents.message_content = True diff --git a/examples/views/link.py b/examples/views/link.py index 3838fb72f35e..13394891c06f 100644 --- a/examples/views/link.py +++ b/examples/views/link.py @@ -7,6 +7,9 @@ class GoogleBot(commands.Bot): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + def __init__(self): intents = discord.Intents.default() intents.message_content = True diff --git a/examples/views/persistent.py b/examples/views/persistent.py index 14b2671905fc..90050ea03447 100644 --- a/examples/views/persistent.py +++ b/examples/views/persistent.py @@ -64,6 +64,9 @@ async def callback(self, interaction: discord.Interaction) -> None: class PersistentViewBot(commands.Bot): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + def __init__(self): intents = discord.Intents.default() intents.message_content = True diff --git a/examples/views/tic_tac_toe.py b/examples/views/tic_tac_toe.py index eff638ae37a1..f016c72ca546 100644 --- a/examples/views/tic_tac_toe.py +++ b/examples/views/tic_tac_toe.py @@ -121,6 +121,9 @@ def check_board_winner(self): class TicTacToeBot(commands.Bot): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + def __init__(self): intents = discord.Intents.default() intents.message_content = True From 24d3a1211798d050199aa3c7650c6b1d61f0e3ed Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sat, 16 Aug 2025 05:36:50 -0400 Subject: [PATCH 067/138] Remove unused row parameter from TextDisplay --- discord/ui/text_display.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index 399428a06b85..b6f908748c55 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from typing import TYPE_CHECKING, Literal, Optional, TypeVar @@ -56,7 +57,7 @@ class TextDisplay(Item[V]): __slots__ = ('content',) - def __init__(self, content: str, *, row: Optional[int] = None, id: Optional[int] = None) -> None: + def __init__(self, content: str, *, id: Optional[int] = None) -> None: super().__init__() self.content: str = content self.id = id From 4be15b5616db0a2b2a1b02d8a0702a99a3885ba9 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sat, 16 Aug 2025 06:18:14 -0400 Subject: [PATCH 068/138] Add example showcasing Container for Embed-like layouts --- examples/views/embed_like.py | 124 +++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 examples/views/embed_like.py diff --git a/examples/views/embed_like.py b/examples/views/embed_like.py new file mode 100644 index 000000000000..9181d0fdb370 --- /dev/null +++ b/examples/views/embed_like.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from discord.ext import commands +from discord import ui +import discord +import aiohttp + + +class Bot(commands.Bot): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + + def __init__(self): + intents = discord.Intents.default() + super().__init__(command_prefix=commands.when_mentioned, intents=intents) + + async def setup_hook(self) -> None: + # Create a session for making HTTP requests. + self.session = aiohttp.ClientSession() + + async def close(self) -> None: + # Close the session when the bot is shutting down. + await self.session.close() + await super().close() + + async def on_ready(self): + print(f'Logged in as {self.user} (ID: {self.user.id})') + print('------') + + async def get_random_dog_image(self) -> str: + async with self.session.get('https://random.dog/woof.json') as resp: + js = await resp.json() + return js['url'] + + +# This is a row of buttons that will be used in our larger LayoutView later. +# An ActionRow is similar to a View but it can only contain up to 5 buttons or 1 select menu. +# Similar to a View it can be inherited to make it easier to manage. +class EmbedChangeButtons(ui.ActionRow): + def __init__(self, view: 'EmbedLikeView') -> None: + self.__view = view + super().__init__() + + @ui.button(label='New Image', style=discord.ButtonStyle.gray) + async def new_image(self, interaction: discord.Interaction[Bot], button: discord.ui.Button) -> None: + url = await interaction.client.get_random_dog_image() + self.__view.thumbnail.media.url = url + await interaction.response.edit_message(view=self.__view) + + @ui.button(label='Change Text', style=discord.ButtonStyle.primary) + async def change_text(self, interaction: discord.Interaction[Bot], button: discord.ui.Button) -> None: + await interaction.response.send_modal(ChangeTextModal(self.__view)) + + +# This is a simple modal to allow the content of the text portion of the "embed" to be changed by the user. +class ChangeTextModal(ui.Modal, title='Change Text'): + new_text = ui.TextInput(label='The new text', style=discord.TextStyle.long) + + def __init__(self, view: 'EmbedLikeView') -> None: + self.__view = view + self.new_text.default = view.random_text.content + super().__init__() + + async def on_submit(self, interaction: discord.Interaction, /) -> None: + self.__view.random_text.content = str(self.new_text.value) + await interaction.response.edit_message(view=self.__view) + self.stop() + + +# This defines a simple LayoutView that uses a Container to wrap its contents +# A Container is similar to an Embed, in that it has an accent colour and darkened background. +# It differs from an Embed in that it can contain other items, such as buttons, galleries, or sections, etc. +class EmbedLikeView(ui.LayoutView): + def __init__(self, *, url: str) -> None: + super().__init__() + + # When we want to use text somewhere, we can wrap it in a TextDisplay object so it becomes an Item. + self.random_text = ui.TextDisplay('This is a random dog image! Press the button to change it and this text!') + # A thumbnail is an Item that can be used to display an image as a thumbnail. + # It needs to be wrapped inside a Section object to be used. + # A Section is a container that can hold 3 TextDisplay and an accessory. + # The accessory can either be a Thumbnail or a Button. + # Since we're emulating an Embed, we will use a Thumbnail. + self.thumbnail = ui.Thumbnail(media=url) + self.section = ui.Section(self.random_text, accessory=self.thumbnail) + self.buttons = EmbedChangeButtons(self) + + # Wrap all of this inside a Container + # To visualize how this looks, you can think of it similar to this ASCII diagram: + # +----------------------Container--------------------+ + # | +--------------------Section--------------------+ | + # | | +----------------------------+ +-Thumbnail-+ | | + # | | | TextDisplay | | Accessory | | | + # | | | | | | | | + # | | | | | | | | + # | | | | | | | | + # | | +----------------------------+ +-----------+ | | + # | +-----------------------------------------------+ | + # | +------------------ActionRow--------------------+ | + # | |+-------------+ +-------------+ | | + # | || Button A | | Button B | | | + # | |+-------------+ +-------------+ | | + # | +-----------------------------------------------+ | + # +---------------------------------------------------+ + + # If you want the "embed" to have multiple images you can add a MediaGallery item + # to the container as well, which lets you have up to 10 images in a grid-like gallery. + + container = ui.Container(self.section, self.buttons, accent_color=discord.Color.blurple()) + self.add_item(container) + + +bot = Bot() + + +@bot.command() +async def embed(ctx: commands.Context[Bot]): + """Shows the basic Embed-like LayoutView.""" + url = await ctx.bot.get_random_dog_image() + # Note that when sending LayoutViews, you cannot send any content, embeds, stickers, or polls. + await ctx.send(view=EmbedLikeView(url=url)) + + +bot.run('token') From 22d6e8d0aa56beb6669e8ba72fb509b438df8bbc Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sun, 17 Aug 2025 19:48:18 -0400 Subject: [PATCH 069/138] Add example showcasing how to do a settings panel --- examples/views/settings.py | 258 +++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 examples/views/settings.py diff --git a/examples/views/settings.py b/examples/views/settings.py new file mode 100644 index 000000000000..daf02e250aa7 --- /dev/null +++ b/examples/views/settings.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List, Optional, Union +from discord.ext import commands +from discord import ui +import discord +import enum + + +class FruitType(enum.Enum): + apple = "Apple" + banana = "Banana" + orange = "Orange" + grape = "Grape" + mango = "Mango" + watermelon = "Watermelon" + coconut = "Coconut" + + @property + def emoji(self) -> str: + emojis = { + "Apple": "🍎", + "Banana": "🍌", + "Orange": "🍊", + "Grape": "🍇", + "Mango": "🥭", + "Watermelon": "🍉", + "Coconut": "🥥", + } + return emojis[self.value] + + def as_option(self) -> discord.SelectOption: + return discord.SelectOption(label=self.value, emoji=self.emoji, value=self.name) + + +# This is where we'll store our settings for the purpose of this example. +# In a real application you would want to store this in a database or file. +@dataclass +class Settings: + fruit_type: FruitType = FruitType.apple + channel: Optional[discord.PartialMessageable] = None + members: List[Union[discord.Member, discord.User]] = field(default_factory=list) + count: int = 1 + silent: bool = False + + +class Bot(commands.Bot): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + + def __init__(self): + intents = discord.Intents.default() + super().__init__(command_prefix=commands.when_mentioned, intents=intents) + self.settings: Settings = Settings() + + async def on_ready(self): + print(f'Logged in as {self.user} (ID: {self.user.id})') + print('------') + + +class FruitsSetting(ui.ActionRow['SettingsView']): + def __init__(self, settings: Settings): + super().__init__() + self.settings = settings + self.update_options() + + def update_options(self): + for option in self.select_fruit.options: + if option.value == self.settings.fruit_type.name: + option.default = True + + @ui.select(placeholder='Select a fruit', options=[fruit.as_option() for fruit in FruitType]) + async def select_fruit(self, interaction: discord.Interaction[Bot], select: discord.ui.Select) -> None: + self.settings.fruit_type = FruitType[select.values[0]] + self.update_options() + await interaction.response.edit_message(view=self.view) + + +class ChannelSetting(ui.ActionRow['SettingsView']): + def __init__(self, settings: Settings): + super().__init__() + self.settings = settings + if settings.channel is not None: + self.select_channel.default_values = [ + discord.SelectDefaultValue(id=settings.channel.id, type=discord.SelectDefaultValueType.channel) + ] + + @ui.select( + placeholder='Select a channel', + channel_types=[discord.ChannelType.text, discord.ChannelType.public_thread], + max_values=1, + min_values=0, + cls=ui.ChannelSelect, + ) + async def select_channel(self, interaction: discord.Interaction[Bot], select: ui.ChannelSelect) -> None: + if select.values: + channel = select.values[0] + self.settings.channel = interaction.client.get_partial_messageable( + channel.id, guild_id=channel.guild_id, type=channel.type + ) + select.default_values = [discord.SelectDefaultValue(id=channel.id, type=discord.SelectDefaultValueType.channel)] + else: + self.settings.channel = None + select.default_values = [] + await interaction.response.edit_message(view=self.view) + + +class MembersSetting(ui.ActionRow['SettingsView']): + def __init__(self, settings: Settings): + super().__init__() + self.settings = settings + self.update_options() + + def update_options(self): + self.select_members.default_values = [ + discord.SelectDefaultValue(id=member.id, type=discord.SelectDefaultValueType.user) + for member in self.settings.members + ] + + @ui.select(placeholder='Select members', max_values=5, min_values=0, cls=ui.UserSelect) + async def select_members(self, interaction: discord.Interaction[Bot], select: ui.UserSelect) -> None: + self.settings.members = select.values + self.update_options() + await interaction.response.edit_message(view=self.view) + + +class CountModal(ui.Modal, title='Set emoji count'): + count = ui.TextInput(label='Count', style=discord.TextStyle.short, default='1', required=True) + + def __init__(self, view: 'SettingsView', button: SetCountButton): + super().__init__() + self.view = view + self.settings = view.settings + self.button = button + + async def on_submit(self, interaction: discord.Interaction[Bot]) -> None: + try: + self.settings.count = int(self.count.value) + self.button.label = str(self.settings.count) + await interaction.response.edit_message(view=self.view) + except ValueError: + await interaction.response.send_message('Invalid count. Please enter a number.', ephemeral=True) + + +class SetCountButton(ui.Button['SettingsView']): + def __init__(self, settings: Settings): + super().__init__(label=str(settings.count), style=discord.ButtonStyle.secondary) + self.settings = settings + + async def callback(self, interaction: discord.Interaction[Bot]) -> None: + # Tell the type checker that a view is attached already + assert self.view is not None + await interaction.response.send_modal(CountModal(self.view, self)) + + +class NotificationToggleButton(ui.Button['SettingsView']): + def __init__(self, settings: Settings): + super().__init__(label='\N{BELL}', style=discord.ButtonStyle.green) + self.settings = settings + self.update_button() + + def update_button(self): + if self.settings.silent: + self.label = '\N{BELL WITH CANCELLATION STROKE} Disabled' + self.style = discord.ButtonStyle.red + else: + self.label = '\N{BELL} Enabled' + self.style = discord.ButtonStyle.green + + async def callback(self, interaction: discord.Interaction[Bot]) -> None: + self.settings.silent = not self.settings.silent + self.update_button() + await interaction.response.edit_message(view=self.view) + + +class SettingsView(ui.LayoutView): + row = ui.ActionRow() + + def __init__(self, settings: Settings): + super().__init__() + self.settings = settings + + # For this example, we'll use multiple sections to organize the settings. + container = ui.Container() + header = ui.TextDisplay('# Settings\n-# This is an example to showcase how to do settings.') + container.add_item(header) + container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.large)) + + self.count_button = SetCountButton(self.settings) + container.add_item( + ui.Section( + ui.TextDisplay('## Emoji Count\n-# This is the number of times the emoji will be repeated in the message.'), + accessory=self.count_button, + ) + ) + container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.small)) + container.add_item( + ui.Section( + ui.TextDisplay( + '## Notification Settings\n-# This controls whether the bot will use silent messages or not.' + ), + accessory=NotificationToggleButton(self.settings), + ) + ) + container.add_item(ui.Separator(spacing=discord.SeparatorSpacing.large)) + container.add_item(ui.TextDisplay('## Fruit Selection\n-# This is the fruit that is shown in the message.')) + container.add_item(FruitsSetting(self.settings)) + container.add_item(ui.TextDisplay('## Channel Selection\n-# This is the channel where the message will be sent.')) + container.add_item(ChannelSetting(self.settings)) + container.add_item( + ui.TextDisplay('## Member Selection\n-# These are the members that will be mentioned in the message.') + ) + container.add_item(MembersSetting(self.settings)) + self.add_item(container) + + # Swap the row so it's at the end + self.remove_item(self.row) + self.add_item(self.row) + + @row.button(label='Finish', style=discord.ButtonStyle.green) + async def finish_button(self, interaction: discord.Interaction[Bot], button: ui.Button) -> None: + # Edit the message to make it the interaction response... + await interaction.response.edit_message(view=self) + # ...and then send a confirmation message. + await interaction.followup.send(f'Settings saved.', ephemeral=True) + # Then delete the settings panel + self.stop() + await interaction.delete_original_response() + + +bot = Bot() + + +@bot.command() +async def settings(ctx: commands.Context[Bot]): + """Shows the settings view.""" + view = SettingsView(ctx.bot.settings) + await ctx.send(view=view) + + +@bot.command() +async def send(ctx: commands.Context[Bot]): + """Sends the message with the current settings.""" + settings = ctx.bot.settings + + if settings.channel is None: + await ctx.send('No channel is configured. Please use the settings command to set one.') + return + + # This example is super silly, so don't do this for real. It's annoying. + content = ' '.join(settings.fruit_type.emoji for _ in range(settings.count)) + mentions = ' '.join(member.mention for member in settings.members) + + await settings.channel.send(content=f'{mentions} {content}', silent=settings.silent) + + +bot.run('token') From eb84d0dfcb0bfc77d1bff7cff519887f9272f3c7 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sun, 17 Aug 2025 20:02:21 -0400 Subject: [PATCH 070/138] Add example showcasing ui.Label in modals --- examples/modals/label.py | 98 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 examples/modals/label.py diff --git a/examples/modals/label.py b/examples/modals/label.py new file mode 100644 index 000000000000..697744b80a4a --- /dev/null +++ b/examples/modals/label.py @@ -0,0 +1,98 @@ +import datetime +import discord +from discord import app_commands + +import traceback + +# The guild in which this slash command will be registered. +# It is recommended to have a test guild to separate from your "production" bot +TEST_GUILD = discord.Object(0) + + +class MyClient(discord.Client): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + + def __init__(self) -> None: + # Just default intents and a `discord.Client` instance + # We don't need a `commands.Bot` instance because we are not + # creating text-based commands. + intents = discord.Intents.default() + super().__init__(intents=intents) + + # We need an `discord.app_commands.CommandTree` instance + # to register application commands (slash commands in this case) + self.tree = app_commands.CommandTree(self) + + async def on_ready(self): + print(f'Logged in as {self.user} (ID: {self.user.id})') + print('------') + + async def setup_hook(self) -> None: + # Sync the application command with Discord. + await self.tree.sync(guild=TEST_GUILD) + + +class TimeoutModal(discord.ui.Modal, title='Timeout Member'): + # We can use a Label to attach a rich label and description to our item. + duration = discord.ui.Label( + text='Duration', + description='How long to timeout the member for.', + component=discord.ui.Select( + options=[ + discord.SelectOption(label='1 minute', value='60'), + discord.SelectOption(label='5 minutes', value='300'), + discord.SelectOption(label='10 minutes', value='600'), + discord.SelectOption(label='30 minutes', value='1800'), + discord.SelectOption(label='1 hour', value='3600'), + ], + ), + ) + + reason = discord.ui.Label( + text='Reason', + description='The reason for the timeout.', + component=discord.ui.TextInput( + style=discord.TextStyle.short, + max_length=256, + ), + ) + + def __init__(self, member: discord.Member) -> None: + self.member = member + super().__init__() + + async def on_submit(self, interaction: discord.Interaction): + # Tell the type checker what our components are... + assert isinstance(self.duration.component, discord.ui.Select) + assert isinstance(self.reason.component, discord.ui.TextInput) + + until = discord.utils.utcnow() + datetime.timedelta(seconds=int(self.duration.component.values[0])) + await self.member.timeout(until, reason=self.reason.component.value) + await interaction.response.send_message( + f'Timeout {self.member.mention} until {discord.utils.format_dt(until)} with reason: {self.reason.component.value}', + ephemeral=True, + ) + + async def on_error(self, interaction: discord.Interaction, error: Exception) -> None: + await interaction.response.send_message('Oops! Something went wrong.', ephemeral=True) + + # Make sure we know what the error actually is + traceback.print_exception(type(error), error, error.__traceback__) + + +client = MyClient() + + +@client.tree.command(guild=TEST_GUILD, description="Timeout a member") +async def timeout(interaction: discord.Interaction, member: discord.Member): + # Send the modal with an instance of our `TimeoutModal` class + # Since modals require an interaction, they cannot be done as a response to a text command. + # They can only be done as a response to either an application command or a button press. + + # Do note that this example is illustrative, Discord comes with this timeout feature natively + # and does not need this command or modal. + await interaction.response.send_modal(TimeoutModal(member)) + + +client.run('token') From 2a69ac4ca8a5dc5ac307335f32d7c4c6aeff355e Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 18 Aug 2025 04:34:24 -0400 Subject: [PATCH 071/138] Document difference between View and LayoutView --- discord/ui/view.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 7b4fdd1c45d6..d2ff034d6eaa 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -326,7 +326,8 @@ def children(self) -> List[Item[Self]]: @classmethod def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> Union[View, LayoutView]: - """Converts a message's components into a :class:`View`. + """Converts a message's components into a :class:`View` + or :class:`LayoutView`. The :attr:`.Message.components` of a message are read-only and separate types from those in the ``discord.ui`` namespace. @@ -768,6 +769,9 @@ class LayoutView(BaseView): This object must be inherited to create a UI within Discord. + This differs from a :class:`View` in that it supports all component types + and uses what Discord refers to as "v2 components". + You can find usage examples in the :resource:`repository ` .. versionadded:: 2.6 From 5887ce14304485839efa7cef5cebded57231d27f Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 18 Aug 2025 04:35:41 -0400 Subject: [PATCH 072/138] Fix settings example not resetting option default state --- examples/views/settings.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/views/settings.py b/examples/views/settings.py index daf02e250aa7..00b19715468b 100644 --- a/examples/views/settings.py +++ b/examples/views/settings.py @@ -67,8 +67,7 @@ def __init__(self, settings: Settings): def update_options(self): for option in self.select_fruit.options: - if option.value == self.settings.fruit_type.name: - option.default = True + option.default = option.value == self.settings.fruit_type.name @ui.select(placeholder='Select a fruit', options=[fruit.as_option() for fruit in FruitType]) async def select_fruit(self, interaction: discord.Interaction[Bot], select: discord.ui.Select) -> None: From 9ef5b054e5aa910bc1577d9827873c338c91dce8 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 18 Aug 2025 04:49:20 -0400 Subject: [PATCH 073/138] Refactor total children count to an overrideable method --- discord/ui/action_row.py | 17 +++++++++-------- discord/ui/container.py | 22 ++++++++++++---------- discord/ui/section.py | 17 +++++++++-------- discord/ui/view.py | 20 +++++++++++--------- 4 files changed, 41 insertions(+), 35 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 7d2fc64a94c0..dbe455462074 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -234,7 +234,8 @@ def add_item(self, item: Item[Any]) -> Self: TypeError An :class:`Item` was not passed. ValueError - Maximum number of children has been exceeded (5). + Maximum number of children has been exceeded (5) + or (40) for the entire view. """ if (self._weight + item.width) > 5: @@ -246,14 +247,14 @@ def add_item(self, item: Item[Any]) -> Self: if not isinstance(item, Item): raise TypeError(f'expected Item not {item.__class__.__name__}') + if self._view: + self._view._add_count(1) + item._update_view(self.view) item._parent = self self._weight += 1 self._children.append(item) - if self._view: - self._view._total_children += 1 - return self def remove_item(self, item: Item[Any]) -> Self: @@ -273,8 +274,8 @@ def remove_item(self, item: Item[Any]) -> Self: except ValueError: pass else: - if self._view and self._view._is_layout(): - self._view._total_children -= 1 + if self._view: + self._view._add_count(-1) self._weight -= 1 return self @@ -305,8 +306,8 @@ def clear_items(self) -> Self: This function returns the class instance to allow for fluent-style chaining. """ - if self._view and self._view._is_layout(): - self._view._total_children -= len(self._children) + if self._view: + self._view._add_count(-len(self._children)) self._children.clear() self._weight = 0 return self diff --git a/discord/ui/container.py b/discord/ui/container.py index bbd9239891f7..8e9130320c0e 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -296,18 +296,20 @@ def add_item(self, item: Item[Any]) -> Self: ------ TypeError An :class:`Item` was not passed. + ValueError + Maximum number of children has been exceeded (40) for the entire view. """ if not isinstance(item, Item): raise TypeError(f'expected Item not {item.__class__.__name__}') + if item._has_children() and self._view: + self._view._add_count(len(tuple(item.walk_children()))) # type: ignore + elif self._view: + self._view._add_count(1) + self._children.append(item) item._update_view(self.view) item._parent = self - - if item._has_children() and self._view: - self._view._total_children += len(tuple(item.walk_children())) # type: ignore - elif self._view: - self._view._total_children += 1 return self def remove_item(self, item: Item[Any]) -> Self: @@ -327,11 +329,11 @@ def remove_item(self, item: Item[Any]) -> Self: except ValueError: pass else: - if self._view and self._view._is_layout(): + if self._view: if item._has_children(): - self._view._total_children -= len(tuple(item.walk_children())) # type: ignore + self._view._add_count(-len(tuple(item.walk_children()))) # type: ignore else: - self._view._total_children -= 1 + self._view._add_count(-1) return self def find_item(self, id: int, /) -> Optional[Item[V]]: @@ -361,7 +363,7 @@ def clear_items(self) -> Self: chaining. """ - if self._view and self._view._is_layout(): - self._view._total_children -= len(tuple(self.walk_children())) + if self._view: + self._view._add_count(-len(tuple(self.walk_children()))) self._children.clear() return self diff --git a/discord/ui/section.py b/discord/ui/section.py index 745f91ab3d63..fc1b770bafef 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -145,23 +145,24 @@ def add_item(self, item: Union[str, Item[Any]]) -> Self: TypeError An :class:`Item` or :class:`str` was not passed. ValueError - Maximum number of children has been exceeded (3). + Maximum number of children has been exceeded (3) or (40) + for the entire view. """ if len(self._children) >= 3: - raise ValueError('maximum number of children exceeded') + raise ValueError('maximum number of children exceeded (3)') if not isinstance(item, (Item, str)): raise TypeError(f'expected Item or str not {item.__class__.__name__}') + if self._view: + self._view._add_count(1) + item = item if isinstance(item, Item) else TextDisplay(item) item._update_view(self.view) item._parent = self self._children.append(item) - if self._view: - self._view._total_children += 1 - return self def remove_item(self, item: Item[Any]) -> Self: @@ -182,7 +183,7 @@ def remove_item(self, item: Item[Any]) -> Self: pass else: if self._view: - self._view._total_children -= 1 + self._view._add_count(-1) return self @@ -212,8 +213,8 @@ def clear_items(self) -> Self: This function returns the class instance to allow for fluent-style chaining. """ - if self._view and self._view._is_layout(): - self._view._total_children -= len(self._children) # we don't count the accessory because it is required + if self._view: + self._view._add_count(-len(self._children)) # we don't count the accessory because it is required self._children.clear() return self diff --git a/discord/ui/view.py b/discord/ui/view.py index d2ff034d6eaa..af41285d5da3 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -319,6 +319,9 @@ def timeout(self, value: Optional[float]) -> None: self.__timeout = value + def _add_count(self, value: int) -> None: + self._total_children = max(0, self._total_children + value) + @property def children(self) -> List[Item[Self]]: """List[:class:`Item`]: The list of children attached to this view.""" @@ -419,10 +422,7 @@ def add_item(self, item: Item[Any]) -> Self: if item._has_children(): added += len(tuple(item.walk_children())) # type: ignore - if self._is_layout() and self._total_children + added > 40: - raise ValueError('maximum number of children exceeded') - - self._total_children += added + self._add_count(added) self._children.append(item) return self @@ -446,11 +446,7 @@ def remove_item(self, item: Item[Any]) -> Self: removed = 1 if item._has_children(): removed += len(tuple(item.walk_children())) # type: ignore - - if self._total_children - removed < 0: - self._total_children = 0 - else: - self._total_children -= removed + self._add_count(-removed) return self @@ -822,6 +818,12 @@ def __init_subclass__(cls) -> None: def _is_layout(self) -> bool: return True + def _add_count(self, value: int) -> None: + if self._total_children + value > 40: + raise ValueError('maximum number of children exceeded (40)') + + self._total_children = max(0, self._total_children + value) + def to_components(self): components: List[Dict[str, Any]] = [] for i in self._children: From 98d2113cddbc201b9a39c2cb92786e73c52130e7 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 18 Aug 2025 05:05:05 -0400 Subject: [PATCH 074/138] Hide interaction_check where it isn't really applicable --- docs/interactions/api.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index 9b5409d58b8e..1feeca879873 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -737,7 +737,7 @@ TextInput .. autoclass:: discord.ui.TextInput :members: :inherited-members: - :exclude-members: callback + :exclude-members: callback, interaction_check Container @@ -759,7 +759,7 @@ File .. autoclass:: discord.ui.File :members: :inherited-members: - :exclude-members: callback + :exclude-members: callback, interaction_check Label ~~~~~~ @@ -769,7 +769,7 @@ Label .. autoclass:: discord.ui.Label :members: :inherited-members: - :exclude-members: callback + :exclude-members: callback, interaction_check MediaGallery @@ -780,7 +780,7 @@ MediaGallery .. autoclass:: discord.ui.MediaGallery :members: :inherited-members: - :exclude-members: callback + :exclude-members: callback, interaction_check Section @@ -802,7 +802,7 @@ Separator .. autoclass:: discord.ui.Separator :members: :inherited-members: - :exclude-members: callback + :exclude-members: callback, interaction_check TextDisplay @@ -813,7 +813,7 @@ TextDisplay .. autoclass:: discord.ui.TextDisplay :members: :inherited-members: - :exclude-members: callback + :exclude-members: callback, interaction_check Thumbnail @@ -824,7 +824,7 @@ Thumbnail .. autoclass:: discord.ui.Thumbnail :members: :inherited-members: - :exclude-members: callback + :exclude-members: callback, interaction_check ActionRow From 4854c56d587dce13f793536581696385805a39a8 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 18 Aug 2025 06:11:59 -0400 Subject: [PATCH 075/138] Allow discord.File in places where UnfurledMediaItem or str are allowed --- discord/components.py | 24 +++++++++++++++++------- discord/file.py | 8 ++++++++ discord/ui/file.py | 33 +++++++++++++++++++++++---------- discord/ui/media_gallery.py | 18 ++++++++++-------- discord/ui/thumbnail.py | 26 +++++++++++++++++++------- 5 files changed, 77 insertions(+), 32 deletions(-) diff --git a/discord/components.py b/discord/components.py index 41c0d3788aa5..0986680fc792 100644 --- a/discord/components.py +++ b/discord/components.py @@ -47,6 +47,7 @@ ) from .flags import AttachmentFlags from .colour import Colour +from .file import File from .utils import get_slots, MISSING, _get_as_snowflake from .partial_emoji import PartialEmoji, _EmojiTag @@ -1009,7 +1010,7 @@ class MediaGalleryItem: Parameters ---------- - media: Union[:class:`str`, :class:`UnfurledMediaItem`] + media: Union[:class:`str`, :class:`discord.File`, :class:`UnfurledMediaItem`] The media item data. This can be a string representing a local file uploaded as an attachment in the message, which can be accessed using the ``attachment://`` format, or an arbitrary url. @@ -1029,14 +1030,21 @@ class MediaGalleryItem: def __init__( self, - media: Union[str, UnfurledMediaItem], + media: Union[str, File, UnfurledMediaItem], *, - description: Optional[str] = None, - spoiler: bool = False, + description: Optional[str] = MISSING, + spoiler: bool = MISSING, ) -> None: self.media = media - self.description: Optional[str] = description - self.spoiler: bool = spoiler + + if isinstance(media, File): + if description is MISSING: + description = media.description + if spoiler is MISSING: + spoiler = media.spoiler + + self.description: Optional[str] = None if description is MISSING else description + self.spoiler: bool = bool(spoiler) self._state: Optional[ConnectionState] = None def __repr__(self) -> str: @@ -1048,11 +1056,13 @@ def media(self) -> UnfurledMediaItem: return self._media @media.setter - def media(self, value: Union[str, UnfurledMediaItem]) -> None: + def media(self, value: Union[str, File, UnfurledMediaItem]) -> None: if isinstance(value, str): self._media = UnfurledMediaItem(value) elif isinstance(value, UnfurledMediaItem): self._media = value + elif isinstance(value, File): + self._media = UnfurledMediaItem(value.uri) else: raise TypeError(f'Expected a str or UnfurledMediaItem, not {value.__class__.__name__}') diff --git a/discord/file.py b/discord/file.py index 7e4df415b241..c0649d539c46 100644 --- a/discord/file.py +++ b/discord/file.py @@ -130,6 +130,14 @@ def filename(self) -> str: def filename(self, value: str) -> None: self._filename, self.spoiler = _strip_spoiler(value) + @property + def uri(self) -> str: + """:class:`str`: Returns the ``attachment://`` URI for this file. + + .. versionadded:: 2.6 + """ + return f'attachment://{self.filename}' + def reset(self, *, seek: Union[int, bool] = True) -> None: # The `seek` parameter is needed because # the retry-loop is iterated over multiple times diff --git a/discord/ui/file.py b/discord/ui/file.py index 746f18fe059c..92b927ac0831 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -21,13 +21,17 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from typing import TYPE_CHECKING, Literal, Optional, TypeVar, Union + from .item import Item from ..components import FileComponent, UnfurledMediaItem from ..enums import ComponentType +from ..utils import MISSING +from ..file import File as SendableFile if TYPE_CHECKING: from typing_extensions import Self @@ -60,7 +64,7 @@ class MyView(ui.LayoutView): Parameters ---------- - media: Union[:class:`str`, :class:`.UnfurledMediaItem`] + media: Union[:class:`str`, :class:`.UnfurledMediaItem`, :class:`discord.File`] This file's media. If this is a string it must point to a local file uploaded within the parent view of this item, and must meet the ``attachment://`` format. @@ -78,17 +82,24 @@ class MyView(ui.LayoutView): def __init__( self, - media: Union[str, UnfurledMediaItem], + media: Union[str, UnfurledMediaItem, SendableFile], *, - spoiler: bool = False, + spoiler: bool = MISSING, id: Optional[int] = None, ) -> None: super().__init__() - self._underlying = FileComponent._raw_construct( - media=UnfurledMediaItem(media) if isinstance(media, str) else media, - spoiler=spoiler, - id=id, - ) + if isinstance(media, SendableFile): + self._underlying = FileComponent._raw_construct( + media=UnfurledMediaItem(media.uri), + spoiler=media.spoiler if spoiler is MISSING else spoiler, + id=id, + ) + else: + self._underlying = FileComponent._raw_construct( + media=UnfurledMediaItem(media) if isinstance(media, str) else media, + spoiler=bool(spoiler), + id=id, + ) self.id = id def _is_v2(self): @@ -108,13 +119,15 @@ def media(self) -> UnfurledMediaItem: return self._underlying.media @media.setter - def media(self, value: Union[str, UnfurledMediaItem]) -> None: + def media(self, value: Union[str, SendableFile, UnfurledMediaItem]) -> None: if isinstance(value, str): self._underlying.media = UnfurledMediaItem(value) elif isinstance(value, UnfurledMediaItem): self._underlying.media = value + elif isinstance(value, SendableFile): + self._underlying.media = UnfurledMediaItem(value.uri) else: - raise TypeError(f'expected a str or UnfurledMediaItem, not {value.__class__.__name__!r}') + raise TypeError(f'expected a str or UnfurledMediaItem or File, not {value.__class__.__name__!r}') @property def url(self) -> str: diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index 8d9a1c9e1731..ddcf581fadc5 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -27,6 +27,8 @@ from .item import Item from ..enums import ComponentType +from ..utils import MISSING +from ..file import File from ..components import ( MediaGalleryItem, MediaGalleryComponent, @@ -110,9 +112,9 @@ def _is_v2(self) -> bool: def add_item( self, *, - media: Union[str, UnfurledMediaItem], - description: Optional[str] = None, - spoiler: bool = False, + media: Union[str, File, UnfurledMediaItem], + description: Optional[str] = MISSING, + spoiler: bool = MISSING, ) -> Self: """Adds an item to this gallery. @@ -121,7 +123,7 @@ def add_item( Parameters ---------- - media: Union[:class:`str`, :class:`.UnfurledMediaItem`] + media: Union[:class:`str`, :class:`discord.File`, :class:`.UnfurledMediaItem`] The media item data. This can be a string representing a local file uploaded as an attachment in the message, which can be accessed using the ``attachment://`` format, or an arbitrary url. @@ -176,9 +178,9 @@ def insert_item_at( self, index: int, *, - media: Union[str, UnfurledMediaItem], - description: Optional[str] = None, - spoiler: bool = False, + media: Union[str, File, UnfurledMediaItem], + description: Optional[str] = MISSING, + spoiler: bool = MISSING, ) -> Self: """Inserts an item before a specified index to the media gallery. @@ -189,7 +191,7 @@ def insert_item_at( ---------- index: :class:`int` The index of where to insert the field. - media: Union[:class:`str`, :class:`.UnfurledMediaItem`] + media: Union[:class:`str`, :class:`discord.File`, :class:`.UnfurledMediaItem`] The media item data. This can be a string representing a local file uploaded as an attachment in the message, which can be accessed using the ``attachment://`` format, or an arbitrary url. diff --git a/discord/ui/thumbnail.py b/discord/ui/thumbnail.py index 855b27a27abd..b921ecee7ef0 100644 --- a/discord/ui/thumbnail.py +++ b/discord/ui/thumbnail.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, TypeVar, Union @@ -28,6 +29,8 @@ from .item import Item from ..enums import ComponentType from ..components import UnfurledMediaItem +from ..file import File +from ..utils import MISSING if TYPE_CHECKING: from typing_extensions import Self @@ -47,7 +50,7 @@ class Thumbnail(Item[V]): Parameters ---------- - media: Union[:class:`str`, :class:`discord.UnfurledMediaItem`] + media: Union[:class:`str`, :class:`discord.File`, :class:`discord.UnfurledMediaItem`] The media of the thumbnail. This can be a URL or a reference to an attachment that matches the ``attachment://filename.extension`` structure. @@ -74,16 +77,23 @@ class Thumbnail(Item[V]): def __init__( self, - media: Union[str, UnfurledMediaItem], + media: Union[str, File, UnfurledMediaItem], *, - description: Optional[str] = None, - spoiler: bool = False, + description: Optional[str] = MISSING, + spoiler: bool = MISSING, id: Optional[int] = None, ) -> None: super().__init__() + + if isinstance(media, File): + description = description if description is not MISSING else media.description + spoiler = spoiler if spoiler is not MISSING else media.spoiler + media = media.uri + self._media: UnfurledMediaItem = UnfurledMediaItem(media) if isinstance(media, str) else media - self.description: Optional[str] = description - self.spoiler: bool = spoiler + self.description: Optional[str] = None if description is MISSING else description + self.spoiler: bool = bool(spoiler) + self.id = id @property @@ -96,11 +106,13 @@ def media(self) -> UnfurledMediaItem: return self._media @media.setter - def media(self, value: Union[str, UnfurledMediaItem]) -> None: + def media(self, value: Union[str, File, UnfurledMediaItem]) -> None: if isinstance(value, str): self._media = UnfurledMediaItem(value) elif isinstance(value, UnfurledMediaItem): self._media = value + elif isinstance(value, File): + self._media = UnfurledMediaItem(value.uri) else: raise TypeError(f'expected a str or UnfurledMediaItem, not {value.__class__.__name__!r}') From 0c98251087ed70caaf2c867a2570eaae11fb601b Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 18 Aug 2025 06:19:30 -0400 Subject: [PATCH 076/138] Move Locale.language_code documentation to where it belongs --- discord/enums.py | 7 ------- docs/api.rst | 13 +++++++++++-- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/discord/enums.py b/discord/enums.py index 6e6242145e2a..0b66158cb05f 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -805,13 +805,6 @@ def __str__(self) -> str: @property def language_code(self) -> str: - """:class:`str`: Returns the locale's BCP 47 language code in the format of ``language-COUNTRY``. - - This is derived from a predefined mapping based on Discord's supported locales. - If no mapping exists for the current locale, this returns the raw locale value as a fallback. - - .. versionadded:: 2.6 - """ return _UNICODE_LANG_MAP.get(self.value, self.value) diff --git a/docs/api.rst b/docs/api.rst index 43ff502dda10..7a75ba25879b 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3604,6 +3604,15 @@ of :class:`enum.Enum`. The ``vi`` locale. + .. attribute:: language_code + + :class:`str`: Returns the locale's BCP 47 language code in the format of ``language-COUNTRY``. + + This is derived from a predefined mapping based on Discord's supported locales. + If no mapping exists for the current locale, this returns the raw locale value as a fallback. + + .. versionadded:: 2.6 + .. class:: MFALevel @@ -3998,7 +4007,7 @@ of :class:`enum.Enum`. .. class:: StatusDisplayType - Represents which field is of the user's activity is + Represents which field is of the user's activity is displayed in the members list. .. versionadded:: 2.6 @@ -4823,7 +4832,7 @@ AuditLogDiff See also :attr:`OnboardingPrompt.options` :type: List[:class:`OnboardingPromptOption`] - + .. attribute:: default_channels The default channels associated with the onboarding in this guild. From 6f6d990195273f1f1441003451003b9a0e46213a Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 18 Aug 2025 07:13:50 -0400 Subject: [PATCH 077/138] Fix type error with ItemCallbackType --- discord/ui/action_row.py | 3 ++- discord/ui/button.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index dbe455462074..9868bfbc2e5b 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -65,10 +65,11 @@ from ..emoji import Emoji from ..components import SelectOption from ..interactions import Interaction + from .container import Container SelectCallbackDecorator = Callable[[ItemCallbackType['S', BaseSelectT]], BaseSelectT] -S = TypeVar('S', bound=Union['ActionRow', 'LayoutView'], covariant=True) +S = TypeVar('S', bound=Union['ActionRow', 'Container', 'LayoutView'], covariant=True) V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('ActionRow',) diff --git a/discord/ui/button.py b/discord/ui/button.py index a2c0c117f21a..f80065963d9b 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -45,10 +45,11 @@ from .view import BaseView from .action_row import ActionRow + from .container import Container from ..emoji import Emoji from ..types.components import ButtonComponent as ButtonComponentPayload -S = TypeVar('S', bound='Union[BaseView, ActionRow]', covariant=True) +S = TypeVar('S', bound='Union[BaseView, Container, ActionRow]', covariant=True) V = TypeVar('V', bound='BaseView', covariant=True) From 76258d071a6813cc66bfd5ec62f37c1e532a18b0 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 18 Aug 2025 13:27:18 -0400 Subject: [PATCH 078/138] Add note for File.uri and ASCII only names --- discord/file.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/discord/file.py b/discord/file.py index c0649d539c46..2d7260ff164f 100644 --- a/discord/file.py +++ b/discord/file.py @@ -133,6 +133,12 @@ def filename(self, value: str) -> None: @property def uri(self) -> str: """:class:`str`: Returns the ``attachment://`` URI for this file. + This is used in certain places such as embeds or components to refer + to an uploaded file via URL. + + .. info:: + Due to Discord's filename processing it can only reference files + that have fully ASCII filenames. .. versionadded:: 2.6 """ From 13d1fbee58ba51196a4ccae89f38c8c22a1744ff Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 18 Aug 2025 13:33:30 -0400 Subject: [PATCH 079/138] Add more detail to what filenames are allowed in File.uri --- discord/file.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/file.py b/discord/file.py index 2d7260ff164f..e3934e7bc739 100644 --- a/discord/file.py +++ b/discord/file.py @@ -137,8 +137,8 @@ def uri(self) -> str: to an uploaded file via URL. .. info:: - Due to Discord's filename processing it can only reference files - that have fully ASCII filenames. + Due to Discord's filename processing, the filename must be ASCII aphanumeric + with underscores, dashes, and periods. .. versionadded:: 2.6 """ From e00bb0b0f3ac36b1704482c4b9fab623400e2a1c Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 18 Aug 2025 13:44:29 -0400 Subject: [PATCH 080/138] Change info directive to note --- discord/file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/file.py b/discord/file.py index e3934e7bc739..8f9102216960 100644 --- a/discord/file.py +++ b/discord/file.py @@ -136,7 +136,7 @@ def uri(self) -> str: This is used in certain places such as embeds or components to refer to an uploaded file via URL. - .. info:: + .. note:: Due to Discord's filename processing, the filename must be ASCII aphanumeric with underscores, dashes, and periods. From f08c042217ca17ed81e430efed92a5c02b8d3d0e Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 18 Aug 2025 14:33:15 -0400 Subject: [PATCH 081/138] Add content_length method to LayoutView and container items --- discord/ui/action_row.py | 6 ++++++ discord/ui/container.py | 6 ++++++ discord/ui/section.py | 6 ++++++ discord/ui/view.py | 9 +++++++++ 4 files changed, 27 insertions(+) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 9868bfbc2e5b..f4ffc3e2cca2 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -219,6 +219,12 @@ def walk_children(self) -> Generator[Item[V], Any, None]: for child in self.children: yield child + def content_length(self) -> int: + """:class:`int`: Returns the total length of all text content in this action row.""" + from .text_display import TextDisplay + + return sum(len(item.content) for item in self._children if isinstance(item, TextDisplay)) + def add_item(self, item: Item[Any]) -> Self: """Adds an item to this action row. diff --git a/discord/ui/container.py b/discord/ui/container.py index 8e9130320c0e..1dcdca6b22c3 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -281,6 +281,12 @@ def walk_children(self) -> Generator[Item[V], None, None]: if child._has_children(): yield from child.walk_children() # type: ignore + def content_length(self) -> int: + """:class:`int`: Returns the total length of all text content in this container.""" + from .text_display import TextDisplay + + return sum(len(item.content) for item in self.walk_children() if isinstance(item, TextDisplay)) + def add_item(self, item: Item[Any]) -> Self: """Adds an item to this container. diff --git a/discord/ui/section.py b/discord/ui/section.py index fc1b770bafef..c39c54789723 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -128,6 +128,12 @@ def _update_view(self, view) -> None: def _has_children(self): return True + def content_length(self) -> int: + """:class:`int`: Returns the total length of all text content in this section.""" + from .text_display import TextDisplay + + return sum(len(item.content) for item in self._children if isinstance(item, TextDisplay)) + def add_item(self, item: Union[str, Item[Any]]) -> Self: """Adds an item to this section. diff --git a/discord/ui/view.py b/discord/ui/view.py index af41285d5da3..6d2f16024e79 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -327,6 +327,15 @@ def children(self) -> List[Item[Self]]: """List[:class:`Item`]: The list of children attached to this view.""" return self._children.copy() + def content_length(self) -> int: + """:class:`int`: Returns the total length of all text content in the view's items. + + A view is allowed to have a maximum of 4000 display characters across all its items. + """ + from .text_display import TextDisplay + + return sum(len(item.content) for item in self.walk_children() if isinstance(item, TextDisplay)) + @classmethod def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> Union[View, LayoutView]: """Converts a message's components into a :class:`View` From 064cb7af715d6177a27379d4eef1662cee8d8679 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 18 Aug 2025 14:39:07 -0400 Subject: [PATCH 082/138] Add View.total_children_count --- discord/ui/view.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 6d2f16024e79..32065ca2a0b4 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -327,14 +327,10 @@ def children(self) -> List[Item[Self]]: """List[:class:`Item`]: The list of children attached to this view.""" return self._children.copy() - def content_length(self) -> int: - """:class:`int`: Returns the total length of all text content in the view's items. - - A view is allowed to have a maximum of 4000 display characters across all its items. - """ - from .text_display import TextDisplay - - return sum(len(item.content) for item in self.walk_children() if isinstance(item, TextDisplay)) + @property + def total_children_count(self) -> int: + """:class:`int`: The total number of children in this view, including those from nested items.""" + return self._total_children @classmethod def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> Union[View, LayoutView]: @@ -693,12 +689,10 @@ class View(BaseView): if TYPE_CHECKING: @classmethod - def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> View: - ... + def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> View: ... @classmethod - def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View: - ... + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View: ... def __init_subclass__(cls) -> None: super().__init_subclass__() @@ -791,12 +785,10 @@ class LayoutView(BaseView): if TYPE_CHECKING: @classmethod - def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> LayoutView: - ... + def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> LayoutView: ... @classmethod - def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> LayoutView: - ... + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> LayoutView: ... def __init__(self, *, timeout: Optional[float] = 180.0) -> None: super().__init__(timeout=timeout) @@ -846,6 +838,15 @@ def add_item(self, item: Item[Any]) -> Self: super().add_item(item) return self + def content_length(self) -> int: + """:class:`int`: Returns the total length of all text content in the view's items. + + A view is allowed to have a maximum of 4000 display characters across all its items. + """ + from .text_display import TextDisplay + + return sum(len(item.content) for item in self.walk_children() if isinstance(item, TextDisplay)) + class ViewStore: def __init__(self, state: ConnectionState): From fc12a41cdfd8b4722794c75dfe61a3cb556fc01b Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 18 Aug 2025 14:49:32 -0400 Subject: [PATCH 083/138] Run black --- discord/ui/view.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 32065ca2a0b4..22afae79df8f 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -689,10 +689,12 @@ class View(BaseView): if TYPE_CHECKING: @classmethod - def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> View: ... + def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> View: + ... @classmethod - def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View: ... + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View: + ... def __init_subclass__(cls) -> None: super().__init_subclass__() @@ -785,10 +787,12 @@ class LayoutView(BaseView): if TYPE_CHECKING: @classmethod - def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> LayoutView: ... + def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> LayoutView: + ... @classmethod - def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> LayoutView: ... + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> LayoutView: + ... def __init__(self, *, timeout: Optional[float] = 180.0) -> None: super().__init__(timeout=timeout) From 5d49f11709cdba5565b39753457ae91381b911bc Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 18 Aug 2025 15:00:42 -0400 Subject: [PATCH 084/138] Remove View.from_dict typing --- discord/ui/view.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 22afae79df8f..e260720602f9 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -688,10 +688,6 @@ class View(BaseView): if TYPE_CHECKING: - @classmethod - def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> View: - ... - @classmethod def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View: ... @@ -786,10 +782,6 @@ class LayoutView(BaseView): if TYPE_CHECKING: - @classmethod - def from_dict(cls, data: List[ComponentPayload], *, timeout: Optional[float] = 180.0) -> LayoutView: - ... - @classmethod def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> LayoutView: ... From 714df2cfde9d46dc83c0b69e89a1ad40314ce739 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 18 Aug 2025 14:58:00 -0400 Subject: [PATCH 085/138] Add changelog for v2.6 --- docs/whats_new.rst | 80 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/docs/whats_new.rst b/docs/whats_new.rst index 44db8c3d4042..c5c7fa2a93d0 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -11,6 +11,86 @@ Changelog This page keeps a detailed human friendly rendering of what's new and changed in specific versions. +.. _vp2p6p0: + +v2.6.0 +-------- + +New Features +~~~~~~~~~~~~~~ + +- Add support for Discord's "Components v2" (:issue:`10166`) + - A new :class:`ui.LayoutView` is used to use these components which requires manual layouting. + - Backwards compatibility is maintained with everything, including :class:`ui.DynamicItem`. + - Adds the following new components with their UI counterpart + - :class:`SectionComponent` corresponds to :class:`ui.Section` + - :class:`TextDisplay` corresponds to :class:`ui.TextDisplay` + - :class:`ThumbnailComponent` corresponds to :class:`ui.Thumbnail` + - :class:`MediaGalleryComponent` corresponds to :class:`ui.MediaGallery` + - :class:`FileComponent` corresponds to :class:`ui.File` + - :class:`SeparatorComponent` corresponds to :class:`ui.Separator` + - :class:`Container` corresponds to :class:`ui.Container` + - :class:`ActionRow` corresponds to :class:`ui.ActionRow` + +- Add support for the first phase for :class:`discord.ui.Modal` improvements. + - This allows :class:`discord.ui.Select` within modals + - This also allows :class:`discord.ui.Label` for better control of the forms within modals. + - This changes :attr:`discord.ui.TextInput.label` to be optional and is deprecated over :class:`discord.ui.Label`. + - As of this writing, this Discord update is not available to users yet. + +- Add support for guild tags (also known as primary guilds) (:issue:`10211`) + - This is through the :class:`PrimaryGuild` class. + - You retrieve this via :attr:`Member.primary_guild`. + +- Add support for the new pins endpoint (:issue:`10205`) + - This turns :meth:`abc.Messageable.pins` into an async iterator. + - The old eager behaviour of using ``await`` is still supported, but is now deprecated. + +- Add support for guild onboarding (:issue:`10226`, :issue:`9260`) +- Add support for :attr:`MemberFlags.automod_quarantined_guild_tag` (:issue:`10236`) +- Add support new gradient and holographic role colours (:issue:`10214`, :issue:`10225`) +- Add :attr:`Locale.language_code` attribute (:issue:`10222`) +- Add support for guest invites (:issue:`10220`) +- Add :attr:`File.uri` to get the ``attachment://`` URI of a file +- Add support for :meth:`InteractionResponse.launch_activity` responses (:issue:`10193`) +- Add ability to create a media-only forum channel via ``media`` parameter in :meth:`Guild.create_forum` (:issue:`10170`) +- Add :attr:`Interaction.filesize_limit` (:issue:`10159`) +- Add new colours from the new Discord themes (:issue:`10152`) + - This updates the old :meth:`Colour.dark_theme`, :meth:`Colour.light_theme`, :meth:`Colour.light_embed` and :meth:`Colour.dark_embed` + - This adds :meth:`Colour.ash_theme`, :meth:`Colour.ash_embed`, :meth:`Colour.onyx_theme`, and :meth:`Colour.onyx_embed` + +- Add support for new fields to read in :class:`Activity` (:issue:`10227`) + - Adds the new :class:`StatusDisplayType` enum + +- Add :meth:`Permissions.apps` classmethod category (:issue:`10147`) +- Add more attributes to :class:`app_commands.AppCommandThread` and :class:`app_commands.AppCommandChannel` (:issue:`10180`, :issue:`10252`) + +Bug Fixes +~~~~~~~~~~~ + +- Fix context install decorators to explicitly restrict commands +- Fix error when sending non-interactive views via partial webhooks (:issue:`10235`) +- Fix voice connection issues and upgrade the voice version to 8 (:issue:`10210`) +- Fix calculation of hashed rate limit keys (:issue:`10215`) +- Fix :attr:`Thread.applied_tags` being empty for media channels (:issue:`10178`) +- Fix :meth:`Embed.to_dict` for user-inherited Embed classes (:issue:`10173`) +- Fix potentially stuck ratelimit buckets in certain circumstances (:issue:`10160`) +- Fix ``__bool__`` being incorrect for :class:`Embed` (:issue:`10154`) +- Fix audit log ``automod_rule_trigger_type`` extra being missing (:issue:`10244`) +- Properly transform media channels in app commands (:issue:`10177`) +- |commands| Fix certain converters not working under ``Optional`` type hint in hybrids (:issue:`10239`, :issue:`10245`) + +Miscellaneous +~~~~~~~~~~~~~~ + +- Skip ``GUILD_MEMBER_ADD`` if the member is already cached (:issue:`10238`) +- Deprecate various methods involving guild creation (:issue:`10164`, :issue:`10246`) +- Deprecate the ``with_expiration`` parameter in :meth:`Client.fetch_invite` (:issue:`10259`) +- Allow creating NSFW voice/stage channels (:issue:`10200`) +- The :class:`Invite` is now returned when using :meth:`Invite.delete` or :meth:`Client.delete_invite` (:issue:`10181`) +- Copy Select options when creating View class (:issue:`10143`) +- Update PyNaCl minimum version dependency (:issue:`10127`) + .. _vp2p5p2: v2.5.2 From 9845a53d3db170299088281f81b9a865fab9143d Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 18 Aug 2025 14:58:23 -0400 Subject: [PATCH 086/138] Version bump to v2.6.0 --- discord/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/__init__.py b/discord/__init__.py index 208519a91990..43de4a92af61 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -13,7 +13,7 @@ __author__ = 'Rapptz' __license__ = 'MIT' __copyright__ = 'Copyright 2015-present Rapptz' -__version__ = '2.6.0a' +__version__ = '2.6.0' __path__ = __import__('pkgutil').extend_path(__path__, __name__) @@ -85,7 +85,7 @@ class VersionInfo(NamedTuple): serial: int -version_info: VersionInfo = VersionInfo(major=2, minor=6, micro=0, releaselevel='alpha', serial=0) +version_info: VersionInfo = VersionInfo(major=2, minor=6, micro=0, releaselevel='final', serial=0) logging.getLogger(__name__).addHandler(logging.NullHandler()) From 1bc330a48aeb19a18b25ee2e4c5d2e1e6004cf49 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 18 Aug 2025 15:07:05 -0400 Subject: [PATCH 087/138] Version bump for development --- discord/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/__init__.py b/discord/__init__.py index 43de4a92af61..57eb3295ce6f 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -13,7 +13,7 @@ __author__ = 'Rapptz' __license__ = 'MIT' __copyright__ = 'Copyright 2015-present Rapptz' -__version__ = '2.6.0' +__version__ = '2.7.0a' __path__ = __import__('pkgutil').extend_path(__path__, __name__) @@ -85,7 +85,7 @@ class VersionInfo(NamedTuple): serial: int -version_info: VersionInfo = VersionInfo(major=2, minor=6, micro=0, releaselevel='final', serial=0) +version_info: VersionInfo = VersionInfo(major=2, minor=7, micro=0, releaselevel='alpha', serial=0) logging.getLogger(__name__).addHandler(logging.NullHandler()) From d58c055c5bae90b58a5dfb1dae38b06d90445d46 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 18 Aug 2025 15:07:44 -0400 Subject: [PATCH 088/138] Remove unused import --- discord/ui/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index e260720602f9..d655d33d627f 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -84,7 +84,7 @@ from ..interactions import Interaction from ..message import Message - from ..types.components import ComponentBase as ComponentBasePayload, Component as ComponentPayload + from ..types.components import ComponentBase as ComponentBasePayload from ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload from ..state import ConnectionState from .modal import Modal From 3ef6272e078f63ab3898adff625ae2a5eb36f399 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 18 Aug 2025 19:04:19 -0400 Subject: [PATCH 089/138] Fix error with View.from_message when having multiple items --- discord/ui/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index d655d33d627f..d23b9fd0ac6f 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -380,7 +380,7 @@ def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) if item._is_v2(): raise ValueError(f'{item.__class__.__name__} cannot be added to {view.__class__.__name__}') view.add_item(item) - row += 1 + row += 1 continue item = _component_to_item(component) From 44a44e938fb2bd0bb085d8aa4577abeb01653ad3 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 18 Aug 2025 20:15:44 -0400 Subject: [PATCH 090/138] Reformat entire project with ruff instead of black --- .github/workflows/lint.yml | 6 +- discord/__init__.py | 2 +- discord/__main__.py | 4 +- discord/abc.py | 46 +++--- discord/activity.py | 6 +- discord/app_commands/checks.py | 5 +- discord/app_commands/commands.py | 38 ++--- discord/app_commands/namespace.py | 6 +- discord/app_commands/translator.py | 18 +-- discord/app_commands/tree.py | 39 ++--- discord/appinfo.py | 2 +- discord/asset.py | 8 +- discord/audit_logs.py | 8 +- discord/automod.py | 26 ++-- discord/backoff.py | 9 +- discord/channel.py | 55 +++---- discord/client.py | 200 +++++++++----------------- discord/embeds.py | 3 +- discord/enums.py | 2 +- discord/errors.py | 2 +- discord/ext/commands/_types.py | 11 +- discord/ext/commands/bot.py | 19 ++- discord/ext/commands/cog.py | 1 + discord/ext/commands/context.py | 43 +++--- discord/ext/commands/converter.py | 6 +- discord/ext/commands/cooldowns.py | 4 +- discord/ext/commands/core.py | 63 ++++---- discord/ext/commands/errors.py | 2 +- discord/ext/commands/flags.py | 2 +- discord/ext/commands/help.py | 2 +- discord/ext/commands/parameters.py | 3 +- discord/ext/commands/view.py | 32 ++--- discord/flags.py | 6 +- discord/gateway.py | 4 +- discord/guild.py | 52 +++---- discord/http.py | 30 ++-- discord/interactions.py | 17 +-- discord/invite.py | 2 +- discord/message.py | 52 ++++--- discord/opus.py | 12 +- discord/permissions.py | 10 +- discord/player.py | 15 +- discord/poll.py | 2 +- discord/presences.py | 1 + discord/primary_guild.py | 2 +- discord/role.py | 18 +-- discord/scheduled_event.py | 15 +- discord/shard.py | 2 +- discord/sku.py | 1 - discord/soundboard.py | 4 +- discord/state.py | 11 +- discord/types/automod.py | 3 +- discord/types/command.py | 2 +- discord/types/guild.py | 6 +- discord/types/invite.py | 3 +- discord/types/scheduled_event.py | 9 +- discord/types/webhook.py | 3 +- discord/ui/action_row.py | 16 +-- discord/ui/media_gallery.py | 1 + discord/ui/modal.py | 2 +- discord/ui/section.py | 1 + discord/ui/select.py | 20 ++- discord/ui/separator.py | 1 + discord/ui/view.py | 8 +- discord/user.py | 4 +- discord/utils.py | 84 +++++------ discord/voice_client.py | 6 +- discord/voice_state.py | 2 +- discord/webhook/async_.py | 20 +-- discord/webhook/sync.py | 18 +-- discord/widget.py | 4 +- examples/advanced_startup.py | 2 - examples/app_commands/basic.py | 1 + examples/app_commands/transformers.py | 2 + examples/basic_bot.py | 4 +- examples/basic_voice.py | 10 +- examples/converters.py | 2 +- examples/custom_context.py | 2 +- examples/modals/basic.py | 2 +- examples/modals/label.py | 2 +- examples/secret.py | 3 +- examples/views/counter.py | 1 - examples/views/dropdown.py | 2 +- examples/views/dynamic_counter.py | 1 + examples/views/ephemeral.py | 1 - examples/views/settings.py | 28 ++-- examples/views/tic_tac_toe.py | 1 + pyproject.toml | 9 +- 88 files changed, 485 insertions(+), 730 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 79b7ac8ec766..2cacf8f2b48a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -27,7 +27,7 @@ jobs: - name: Install dependencies id: install-deps run: | - python -m pip install --upgrade pip setuptools wheel black==22.6 requests + python -m pip install --upgrade pip setuptools wheel ruff==0.12 requests pip install -U -r requirements.txt - name: Setup node.js @@ -42,7 +42,7 @@ jobs: warnings: false no-comments: ${{ matrix.python-version != '3.x' }} - - name: Run black + - name: Run ruff if: ${{ always() && steps.install-deps.outcome == 'success' }} run: | - black --check discord examples + ruff format --check discord examples diff --git a/discord/__init__.py b/discord/__init__.py index 57eb3295ce6f..f4d7af42ee76 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -81,7 +81,7 @@ class VersionInfo(NamedTuple): major: int minor: int micro: int - releaselevel: Literal["alpha", "beta", "candidate", "final"] + releaselevel: Literal['alpha', 'beta', 'candidate', 'final'] serial: int diff --git a/discord/__main__.py b/discord/__main__.py index f8556fcdc1ae..455c5e8ed119 100644 --- a/discord/__main__.py +++ b/discord/__main__.py @@ -133,7 +133,7 @@ async def setup(bot): await bot.add_cog({name}(bot)) ''' -_cog_extras = ''' +_cog_extras = """ async def cog_load(self): # loading logic goes here pass @@ -170,7 +170,7 @@ async def cog_after_invoke(self, ctx): # called after a command is called here pass -''' +""" # certain file names and directory names are forbidden diff --git a/discord/abc.py b/discord/abc.py index 979fe91f0dee..535c70abaad4 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -116,7 +116,7 @@ PartialMessageableChannel = Union[TextChannel, VoiceChannel, StageChannel, Thread, DMChannel, PartialMessageable] MessageableChannel = Union[PartialMessageableChannel, GroupChannel] - SnowflakeTime = Union["Snowflake", datetime] + SnowflakeTime = Union['Snowflake', datetime] class PinnedMessage(Message): pinned_at: datetime @@ -140,7 +140,7 @@ def __init__(self, iterator: AsyncIterator[PinnedMessage]) -> None: def __await__(self) -> Generator[Any, None, List[PinnedMessage]]: warnings.warn( - "`await .pins()` is deprecated; use `async for message in .pins()` instead.", + '`await .pins()` is deprecated; use `async for message in .pins()` instead.', DeprecationWarning, stacklevel=2, ) @@ -423,8 +423,7 @@ class GuildChannel: if TYPE_CHECKING: - def __init__(self, *, state: ConnectionState, guild: Guild, data: GuildChannelPayload): - ... + def __init__(self, *, state: ConnectionState, guild: Guild, data: GuildChannelPayload): ... def __str__(self) -> str: return self.name @@ -794,7 +793,6 @@ def permissions_for(self, obj: Union[Member, Role], /) -> Permissions: default = self.guild.default_role if default is None: - if self._state.self_id == obj.id: return Permissions._user_installed_permissions(in_guild=True) else: @@ -907,8 +905,7 @@ async def set_permissions( *, overwrite: Optional[Union[PermissionOverwrite, _Undefined]] = ..., reason: Optional[str] = ..., - ) -> None: - ... + ) -> None: ... @overload async def set_permissions( @@ -917,8 +914,7 @@ async def set_permissions( *, reason: Optional[str] = ..., **permissions: Unpack[_PermissionOverwriteKwargs], - ) -> None: - ... + ) -> None: ... async def set_permissions( self, @@ -1109,8 +1105,7 @@ async def move( category: Optional[Snowflake] = MISSING, sync_permissions: bool = MISSING, reason: Optional[str] = MISSING, - ) -> None: - ... + ) -> None: ... @overload async def move( @@ -1121,8 +1116,7 @@ async def move( category: Optional[Snowflake] = MISSING, sync_permissions: bool = MISSING, reason: str = MISSING, - ) -> None: - ... + ) -> None: ... @overload async def move( @@ -1133,8 +1127,7 @@ async def move( category: Optional[Snowflake] = MISSING, sync_permissions: bool = MISSING, reason: str = MISSING, - ) -> None: - ... + ) -> None: ... @overload async def move( @@ -1145,8 +1138,7 @@ async def move( category: Optional[Snowflake] = MISSING, sync_permissions: bool = MISSING, reason: str = MISSING, - ) -> None: - ... + ) -> None: ... async def move(self, **kwargs: Any) -> None: """|coro| @@ -1428,8 +1420,7 @@ async def send( view: LayoutView, suppress_embeds: bool = ..., silent: bool = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def send( @@ -1444,8 +1435,7 @@ async def send( view: LayoutView, suppress_embeds: bool = ..., silent: bool = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def send( @@ -1465,8 +1455,7 @@ async def send( suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def send( @@ -1486,8 +1475,7 @@ async def send( suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def send( @@ -1507,8 +1495,7 @@ async def send( suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def send( @@ -1528,8 +1515,7 @@ async def send( suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... async def send( self, @@ -2029,7 +2015,7 @@ async def _before_strategy(retrieve: int, before: Optional[Snowflake], limit: Op if limit is None: raise ValueError('history does not support around with limit=None') if limit > 101: - raise ValueError("history max limit 101 when specifying around parameter") + raise ValueError('history max limit 101 when specifying around parameter') # Strange Discord quirk limit = 100 if limit == 101 else limit diff --git a/discord/activity.py b/discord/activity.py index 053d06dc7306..d15da49a51a9 100644 --- a/discord/activity.py +++ b/discord/activity.py @@ -861,13 +861,11 @@ def __repr__(self) -> str: @overload -def create_activity(data: ActivityPayload, state: ConnectionState) -> ActivityTypes: - ... +def create_activity(data: ActivityPayload, state: ConnectionState) -> ActivityTypes: ... @overload -def create_activity(data: None, state: ConnectionState) -> None: - ... +def create_activity(data: None, state: ConnectionState) -> None: ... def create_activity(data: Optional[ActivityPayload], state: ConnectionState) -> Optional[ActivityTypes]: diff --git a/discord/app_commands/checks.py b/discord/app_commands/checks.py index 3fbd677c30b0..0ee65dea6aa0 100644 --- a/discord/app_commands/checks.py +++ b/discord/app_commands/checks.py @@ -327,7 +327,7 @@ async def test(interaction: discord.Interaction): invalid = perms.keys() - Permissions.VALID_FLAGS.keys() if invalid: - raise TypeError(f"Invalid permission(s): {', '.join(invalid)}") + raise TypeError(f'Invalid permission(s): {", ".join(invalid)}') def predicate(interaction: Interaction) -> bool: permissions = interaction.permissions @@ -354,7 +354,7 @@ def bot_has_permissions(**perms: Unpack[_PermissionsKwargs]) -> Callable[[T], T] invalid = set(perms) - set(Permissions.VALID_FLAGS) if invalid: - raise TypeError(f"Invalid permission(s): {', '.join(invalid)}") + raise TypeError(f'Invalid permission(s): {", ".join(invalid)}') def predicate(interaction: Interaction) -> bool: permissions = interaction.app_permissions @@ -371,7 +371,6 @@ def predicate(interaction: Interaction) -> bool: def _create_cooldown_decorator( key: CooldownFunction[Hashable], factory: CooldownFunction[Optional[Cooldown]] ) -> Callable[[T], T]: - mapping: Dict[Any, Cooldown] = {} async def get_bucket( diff --git a/discord/app_commands/commands.py b/discord/app_commands/commands.py index e4a647f71dad..a23682f8b23f 100644 --- a/discord/app_commands/commands.py +++ b/discord/app_commands/commands.py @@ -219,7 +219,7 @@ def validate_context_menu_name(name: str) -> str: def validate_auto_complete_callback( - callback: AutocompleteCallback[GroupT, ChoiceT] + callback: AutocompleteCallback[GroupT, ChoiceT], ) -> AutocompleteCallback[GroupT, ChoiceT]: # This function needs to ensure the following is true: # If self.foo is passed then don't pass command.binding to the callback @@ -1491,9 +1491,9 @@ class shortened to 100 characters. __discord_app_commands_installation_types__: Optional[AppInstallationType] = MISSING __discord_app_commands_default_permissions__: Optional[Permissions] = MISSING __discord_app_commands_has_module__: bool = False - __discord_app_commands_error_handler__: Optional[ - Callable[[Interaction, AppCommandError], Coroutine[Any, Any, None]] - ] = None + __discord_app_commands_error_handler__: Optional[Callable[[Interaction, AppCommandError], Coroutine[Any, Any, None]]] = ( + None + ) def __init_subclass__( cls, @@ -2484,13 +2484,11 @@ def decorator(func: CheckInputParameter) -> CheckInputParameter: @overload -def guild_only(func: None = ...) -> Callable[[T], T]: - ... +def guild_only(func: None = ...) -> Callable[[T], T]: ... @overload -def guild_only(func: T) -> T: - ... +def guild_only(func: T) -> T: ... def guild_only(func: Optional[T] = None) -> Union[T, Callable[[T], T]]: @@ -2541,13 +2539,11 @@ def inner(f: T) -> T: @overload -def private_channel_only(func: None = ...) -> Callable[[T], T]: - ... +def private_channel_only(func: None = ...) -> Callable[[T], T]: ... @overload -def private_channel_only(func: T) -> T: - ... +def private_channel_only(func: T) -> T: ... def private_channel_only(func: Optional[T] = None) -> Union[T, Callable[[T], T]]: @@ -2598,13 +2594,11 @@ def inner(f: T) -> T: @overload -def dm_only(func: None = ...) -> Callable[[T], T]: - ... +def dm_only(func: None = ...) -> Callable[[T], T]: ... @overload -def dm_only(func: T) -> T: - ... +def dm_only(func: T) -> T: ... def dm_only(func: Optional[T] = None) -> Union[T, Callable[[T], T]]: @@ -2697,13 +2691,11 @@ def inner(f: T) -> T: @overload -def guild_install(func: None = ...) -> Callable[[T], T]: - ... +def guild_install(func: None = ...) -> Callable[[T], T]: ... @overload -def guild_install(func: T) -> T: - ... +def guild_install(func: T) -> T: ... def guild_install(func: Optional[T] = None) -> Union[T, Callable[[T], T]]: @@ -2748,13 +2740,11 @@ def inner(f: T) -> T: @overload -def user_install(func: None = ...) -> Callable[[T], T]: - ... +def user_install(func: None = ...) -> Callable[[T], T]: ... @overload -def user_install(func: T) -> T: - ... +def user_install(func: T) -> T: ... def user_install(func: Optional[T] = None) -> Union[T, Callable[[T], T]]: diff --git a/discord/app_commands/namespace.py b/discord/app_commands/namespace.py index 3fa81712cff3..0cac8cb24c85 100644 --- a/discord/app_commands/namespace.py +++ b/discord/app_commands/namespace.py @@ -181,7 +181,7 @@ def _get_resolved_items(cls, interaction: Interaction, resolved: ResolvedData) - guild_id = interaction.guild_id guild = interaction.guild type = AppCommandOptionType.user.value - for (user_id, user_data) in resolved.get('users', {}).items(): + for user_id, user_data in resolved.get('users', {}).items(): try: member_data = members[user_id] except KeyError: @@ -203,7 +203,7 @@ def _get_resolved_items(cls, interaction: Interaction, resolved: ResolvedData) - ) type = AppCommandOptionType.channel.value - for (channel_id, channel_data) in resolved.get('channels', {}).items(): + for channel_id, channel_data in resolved.get('channels', {}).items(): key = ResolveKey(id=channel_id, type=type) if channel_data['type'] in (10, 11, 12): # The guild ID can't be none in this case @@ -220,7 +220,7 @@ def _get_resolved_items(cls, interaction: Interaction, resolved: ResolvedData) - } ) - for (message_id, message_data) in resolved.get('messages', {}).items(): + for message_id, message_data in resolved.get('messages', {}).items(): channel_id = int(message_data['channel_id']) if guild is None: channel = PartialMessageable(state=state, guild_id=guild_id, id=channel_id) diff --git a/discord/app_commands/translator.py b/discord/app_commands/translator.py index 4b6e01d4bfb3..36b1b923c1b1 100644 --- a/discord/app_commands/translator.py +++ b/discord/app_commands/translator.py @@ -76,38 +76,32 @@ class TranslationContext(Generic[_L, _D]): @overload def __init__( self, location: Literal[TranslationContextLocation.command_name], data: Union[Command[Any, ..., Any], ContextMenu] - ) -> None: - ... + ) -> None: ... @overload def __init__( self, location: Literal[TranslationContextLocation.command_description], data: Command[Any, ..., Any] - ) -> None: - ... + ) -> None: ... @overload def __init__( self, location: Literal[TranslationContextLocation.group_name, TranslationContextLocation.group_description], data: Group, - ) -> None: - ... + ) -> None: ... @overload def __init__( self, location: Literal[TranslationContextLocation.parameter_name, TranslationContextLocation.parameter_description], data: Parameter, - ) -> None: - ... + ) -> None: ... @overload - def __init__(self, location: Literal[TranslationContextLocation.choice_name], data: Choice[Any]) -> None: - ... + def __init__(self, location: Literal[TranslationContextLocation.choice_name], data: Choice[Any]) -> None: ... @overload - def __init__(self, location: Literal[TranslationContextLocation.other], data: Any) -> None: - ... + def __init__(self, location: Literal[TranslationContextLocation.other], data: Any) -> None: ... def __init__(self, location: _L, data: _D) -> None: # type: ignore # pyright doesn't like the overloads self.location: _L = location diff --git a/discord/app_commands/tree.py b/discord/app_commands/tree.py index 3099071c01e0..aa446a01f2b9 100644 --- a/discord/app_commands/tree.py +++ b/discord/app_commands/tree.py @@ -419,8 +419,7 @@ def remove_command( *, guild: Optional[Snowflake] = ..., type: Literal[AppCommandType.message, AppCommandType.user], - ) -> Optional[ContextMenu]: - ... + ) -> Optional[ContextMenu]: ... @overload def remove_command( @@ -430,8 +429,7 @@ def remove_command( *, guild: Optional[Snowflake] = ..., type: Literal[AppCommandType.chat_input] = ..., - ) -> Optional[Union[Command[Any, ..., Any], Group]]: - ... + ) -> Optional[Union[Command[Any, ..., Any], Group]]: ... @overload def remove_command( @@ -441,8 +439,7 @@ def remove_command( *, guild: Optional[Snowflake] = ..., type: AppCommandType, - ) -> Optional[Union[Command[Any, ..., Any], ContextMenu, Group]]: - ... + ) -> Optional[Union[Command[Any, ..., Any], ContextMenu, Group]]: ... def remove_command( self, @@ -539,8 +536,7 @@ def get_command( *, guild: Optional[Snowflake] = ..., type: Literal[AppCommandType.message, AppCommandType.user], - ) -> Optional[ContextMenu]: - ... + ) -> Optional[ContextMenu]: ... @overload def get_command( @@ -550,8 +546,7 @@ def get_command( *, guild: Optional[Snowflake] = ..., type: Literal[AppCommandType.chat_input] = ..., - ) -> Optional[Union[Command[Any, ..., Any], Group]]: - ... + ) -> Optional[Union[Command[Any, ..., Any], Group]]: ... @overload def get_command( @@ -561,8 +556,7 @@ def get_command( *, guild: Optional[Snowflake] = ..., type: AppCommandType, - ) -> Optional[Union[Command[Any, ..., Any], ContextMenu, Group]]: - ... + ) -> Optional[Union[Command[Any, ..., Any], ContextMenu, Group]]: ... def get_command( self, @@ -613,8 +607,7 @@ def get_commands( *, guild: Optional[Snowflake] = ..., type: Literal[AppCommandType.message, AppCommandType.user], - ) -> List[ContextMenu]: - ... + ) -> List[ContextMenu]: ... @overload def get_commands( @@ -622,8 +615,7 @@ def get_commands( *, guild: Optional[Snowflake] = ..., type: Literal[AppCommandType.chat_input], - ) -> List[Union[Command[Any, ..., Any], Group]]: - ... + ) -> List[Union[Command[Any, ..., Any], Group]]: ... @overload def get_commands( @@ -631,8 +623,7 @@ def get_commands( *, guild: Optional[Snowflake] = ..., type: AppCommandType, - ) -> Union[List[Union[Command[Any, ..., Any], Group]], List[ContextMenu]]: - ... + ) -> Union[List[Union[Command[Any, ..., Any], Group]], List[ContextMenu]]: ... @overload def get_commands( @@ -640,8 +631,7 @@ def get_commands( *, guild: Optional[Snowflake] = ..., type: Optional[AppCommandType] = ..., - ) -> List[Union[Command[Any, ..., Any], Group, ContextMenu]]: - ... + ) -> List[Union[Command[Any, ..., Any], Group, ContextMenu]]: ... def get_commands( self, @@ -693,8 +683,7 @@ def walk_commands( *, guild: Optional[Snowflake] = ..., type: Literal[AppCommandType.message, AppCommandType.user], - ) -> Generator[ContextMenu, None, None]: - ... + ) -> Generator[ContextMenu, None, None]: ... @overload def walk_commands( @@ -702,8 +691,7 @@ def walk_commands( *, guild: Optional[Snowflake] = ..., type: Literal[AppCommandType.chat_input] = ..., - ) -> Generator[Union[Command[Any, ..., Any], Group], None, None]: - ... + ) -> Generator[Union[Command[Any, ..., Any], Group], None, None]: ... @overload def walk_commands( @@ -711,8 +699,7 @@ def walk_commands( *, guild: Optional[Snowflake] = ..., type: AppCommandType, - ) -> Union[Generator[Union[Command[Any, ..., Any], Group], None, None], Generator[ContextMenu, None, None]]: - ... + ) -> Union[Generator[Union[Command[Any, ..., Any], Group], None, None], Generator[ContextMenu, None, None]]: ... def walk_commands( self, diff --git a/discord/appinfo.py b/discord/appinfo.py index 990c7c2fe356..9dd70f7efc73 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -406,7 +406,7 @@ async def edit( if install_params_scopes is None: install_params = None else: - if "bot" not in install_params_scopes and install_params_permissions is not MISSING: + if 'bot' not in install_params_scopes and install_params_permissions is not MISSING: raise ValueError("'bot' must be in install_params_scopes if install_params_permissions is set") install_params['scopes'] = install_params_scopes diff --git a/discord/asset.py b/discord/asset.py index cbf7dd4b2976..a3ed53c6bcbc 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -50,8 +50,8 @@ ValidStaticFormatTypes = Literal['webp', 'jpeg', 'jpg', 'png'] ValidAssetFormatTypes = Literal['webp', 'jpeg', 'jpg', 'png', 'gif'] -VALID_STATIC_FORMATS = frozenset({"jpeg", "jpg", "webp", "png"}) -VALID_ASSET_FORMATS = VALID_STATIC_FORMATS | {"gif"} +VALID_STATIC_FORMATS = frozenset({'jpeg', 'jpg', 'webp', 'png'}) +VALID_ASSET_FORMATS = VALID_STATIC_FORMATS | {'gif'} MISSING = utils.MISSING @@ -241,7 +241,7 @@ def _from_guild_avatar(cls, state: _State, guild_id: int, member_id: int, avatar format = 'gif' if animated else 'png' return cls( state, - url=f"{cls.BASE}/guilds/{guild_id}/users/{member_id}/avatars/{avatar}.{format}?size=1024", + url=f'{cls.BASE}/guilds/{guild_id}/users/{member_id}/avatars/{avatar}.{format}?size=1024', key=avatar, animated=animated, ) @@ -252,7 +252,7 @@ def _from_guild_banner(cls, state: _State, guild_id: int, member_id: int, banner format = 'gif' if animated else 'png' return cls( state, - url=f"{cls.BASE}/guilds/{guild_id}/users/{member_id}/banners/{banner}.{format}?size=1024", + url=f'{cls.BASE}/guilds/{guild_id}/users/{member_id}/banners/{banner}.{format}?size=1024', key=banner, animated=animated, ) diff --git a/discord/audit_logs.py b/discord/audit_logs.py index 5e434cbc0c1e..c27a793c360c 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -306,14 +306,12 @@ def __repr__(self) -> str: if TYPE_CHECKING: - def __getattr__(self, item: str) -> Any: - ... + def __getattr__(self, item: str) -> Any: ... - def __setattr__(self, key: str, value: Any) -> Any: - ... + def __setattr__(self, key: str, value: Any) -> Any: ... -Transformer = Callable[["AuditLogEntry", Any], Any] +Transformer = Callable[['AuditLogEntry', Any], Any] class AuditLogChanges: diff --git a/discord/automod.py b/discord/automod.py index 61683c269134..5441d9467103 100644 --- a/discord/automod.py +++ b/discord/automod.py @@ -85,32 +85,27 @@ class AutoModRuleAction: __slots__ = ('type', 'channel_id', 'duration', 'custom_message') @overload - def __init__(self, *, channel_id: int = ...) -> None: - ... + def __init__(self, *, channel_id: int = ...) -> None: ... @overload - def __init__(self, *, type: Literal[AutoModRuleActionType.send_alert_message], channel_id: int = ...) -> None: - ... + def __init__(self, *, type: Literal[AutoModRuleActionType.send_alert_message], channel_id: int = ...) -> None: ... @overload - def __init__(self, *, duration: datetime.timedelta = ...) -> None: - ... + def __init__(self, *, duration: datetime.timedelta = ...) -> None: ... @overload - def __init__(self, *, type: Literal[AutoModRuleActionType.timeout], duration: datetime.timedelta = ...) -> None: - ... + def __init__(self, *, type: Literal[AutoModRuleActionType.timeout], duration: datetime.timedelta = ...) -> None: ... @overload - def __init__(self, *, custom_message: str = ...) -> None: - ... + def __init__(self, *, custom_message: str = ...) -> None: ... @overload - def __init__(self, *, type: Literal[AutoModRuleActionType.block_message]) -> None: - ... + def __init__(self, *, type: Literal[AutoModRuleActionType.block_message]) -> None: ... @overload - def __init__(self, *, type: Literal[AutoModRuleActionType.block_message], custom_message: Optional[str] = ...) -> None: - ... + def __init__( + self, *, type: Literal[AutoModRuleActionType.block_message], custom_message: Optional[str] = ... + ) -> None: ... @overload def __init__( @@ -120,8 +115,7 @@ def __init__( channel_id: Optional[int] = ..., duration: Optional[datetime.timedelta] = ..., custom_message: Optional[str] = ..., - ) -> None: - ... + ) -> None: ... def __init__( self, diff --git a/discord/backoff.py b/discord/backoff.py index cfb93ad2332d..f40142a9acfc 100644 --- a/discord/backoff.py +++ b/discord/backoff.py @@ -75,16 +75,13 @@ def __init__(self, base: int = 1, *, integral: T = False): self._randfunc: Callable[..., Union[int, float]] = rand.randrange if integral else rand.uniform @overload - def delay(self: ExponentialBackoff[Literal[False]]) -> float: - ... + def delay(self: ExponentialBackoff[Literal[False]]) -> float: ... @overload - def delay(self: ExponentialBackoff[Literal[True]]) -> int: - ... + def delay(self: ExponentialBackoff[Literal[True]]) -> int: ... @overload - def delay(self: ExponentialBackoff[bool]) -> Union[int, float]: - ... + def delay(self: ExponentialBackoff[bool]) -> Union[int, float]: ... def delay(self) -> Union[int, float]: """Compute the next delay diff --git a/discord/channel.py b/discord/channel.py index 90eb85f6b92f..3bfaeba0f9d7 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -207,7 +207,7 @@ def __init__(self, *, state: ConnectionState, id: int, volume: float): super().__init__(state=state, data=data) def __repr__(self) -> str: - return f"<{self.__class__.__name__} id={self.id} volume={self.volume}>" + return f'<{self.__class__.__name__} id={self.id} volume={self.volume}>' @property def created_at(self) -> Optional[datetime.datetime]: @@ -273,7 +273,7 @@ def __repr__(self) -> str: ('sound', self.sound), ] inner = ' '.join('%s=%r' % t for t in attrs) - return f"<{self.__class__.__name__} {inner}>" + return f'<{self.__class__.__name__} {inner}>' def is_sound(self) -> bool: """:class:`bool`: Whether the effect is a sound or not.""" @@ -457,12 +457,10 @@ def last_message(self) -> Optional[Message]: return self._state._get_message(self.last_message_id) if self.last_message_id else None @overload - async def edit(self) -> Optional[TextChannel]: - ... + async def edit(self) -> Optional[TextChannel]: ... @overload - async def edit(self, *, position: int, reason: Optional[str] = ...) -> None: - ... + async def edit(self, *, position: int, reason: Optional[str] = ...) -> None: ... @overload async def edit( @@ -480,8 +478,7 @@ async def edit( default_thread_slowmode_delay: int = ..., type: ChannelType = ..., overwrites: Mapping[OverwriteKeyT, PermissionOverwrite] = ..., - ) -> TextChannel: - ... + ) -> TextChannel: ... async def edit(self, *, reason: Optional[str] = None, **options: Any) -> Optional[TextChannel]: """|coro| @@ -1551,12 +1548,10 @@ def type(self) -> Literal[ChannelType.voice]: return ChannelType.voice @overload - async def edit(self) -> None: - ... + async def edit(self) -> None: ... @overload - async def edit(self, *, position: int, reason: Optional[str] = ...) -> None: - ... + async def edit(self, *, position: int, reason: Optional[str] = ...) -> None: ... @overload async def edit( @@ -1575,8 +1570,7 @@ async def edit( slowmode_delay: int = ..., status: Optional[str] = ..., reason: Optional[str] = ..., - ) -> VoiceChannel: - ... + ) -> VoiceChannel: ... async def edit(self, *, reason: Optional[str] = None, **options: Any) -> Optional[VoiceChannel]: """|coro| @@ -1917,12 +1911,10 @@ async def fetch_instance(self) -> StageInstance: return StageInstance(guild=self.guild, state=self._state, data=data) @overload - async def edit(self) -> None: - ... + async def edit(self) -> None: ... @overload - async def edit(self, *, position: int, reason: Optional[str] = ...) -> None: - ... + async def edit(self, *, position: int, reason: Optional[str] = ...) -> None: ... @overload async def edit( @@ -1940,8 +1932,7 @@ async def edit( video_quality_mode: VideoQualityMode = ..., slowmode_delay: int = ..., reason: Optional[str] = ..., - ) -> StageChannel: - ... + ) -> StageChannel: ... async def edit(self, *, reason: Optional[str] = None, **options: Any) -> Optional[StageChannel]: """|coro| @@ -2107,12 +2098,10 @@ async def clone( return await self._clone_impl({'nsfw': self.nsfw}, name=name, reason=reason) @overload - async def edit(self) -> None: - ... + async def edit(self) -> None: ... @overload - async def edit(self, *, position: int, reason: Optional[str] = ...) -> None: - ... + async def edit(self, *, position: int, reason: Optional[str] = ...) -> None: ... @overload async def edit( @@ -2123,8 +2112,7 @@ async def edit( nsfw: bool = ..., overwrites: Mapping[OverwriteKeyT, PermissionOverwrite] = ..., reason: Optional[str] = ..., - ) -> CategoryChannel: - ... + ) -> CategoryChannel: ... async def edit(self, *, reason: Optional[str] = None, **options: Any) -> Optional[CategoryChannel]: """|coro| @@ -2658,12 +2646,10 @@ async def clone( ) @overload - async def edit(self) -> None: - ... + async def edit(self) -> None: ... @overload - async def edit(self, *, position: int, reason: Optional[str] = ...) -> None: - ... + async def edit(self, *, position: int, reason: Optional[str] = ...) -> None: ... @overload async def edit( @@ -2686,8 +2672,7 @@ async def edit( default_layout: ForumLayoutType = ..., default_sort_order: ForumOrderType = ..., require_tag: bool = ..., - ) -> ForumChannel: - ... + ) -> ForumChannel: ... async def edit(self, *, reason: Optional[str] = None, **options: Any) -> Optional[ForumChannel]: """|coro| @@ -2895,8 +2880,7 @@ async def create_thread( view: LayoutView, suppress_embeds: bool = ..., reason: Optional[str] = ..., - ) -> ThreadWithMessage: - ... + ) -> ThreadWithMessage: ... @overload async def create_thread( @@ -2918,8 +2902,7 @@ async def create_thread( view: View = ..., suppress_embeds: bool = ..., reason: Optional[str] = ..., - ) -> ThreadWithMessage: - ... + ) -> ThreadWithMessage: ... async def create_thread( self, diff --git a/discord/client.py b/discord/client.py index 353b159f871b..cfd8fb1225a8 100644 --- a/discord/client.py +++ b/discord/client.py @@ -338,7 +338,7 @@ def __init__(self, *, intents: Intents, **options: Unpack[_ClientOptions]) -> No if VoiceClient.warn_nacl: VoiceClient.warn_nacl = False - _log.warning("PyNaCl is not installed, voice will NOT be supported") + _log.warning('PyNaCl is not installed, voice will NOT be supported') async def __aenter__(self) -> Self: await self._async_setup_hook() @@ -774,7 +774,7 @@ async def connect(self, *, reconnect: bool = True) -> None: raise retry = backoff.delay() - _log.exception("Attempting a reconnect in %.2fs", retry) + _log.exception('Attempting a reconnect in %.2fs', retry) await asyncio.sleep(retry) # Always try to RESUME the connection # If the connection is not RESUME-able then the gateway will invalidate the session. @@ -1238,8 +1238,7 @@ async def wait_for( *, check: Optional[Callable[[RawAppCommandPermissionsUpdateEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawAppCommandPermissionsUpdateEvent: - ... + ) -> RawAppCommandPermissionsUpdateEvent: ... @overload async def wait_for( @@ -1249,8 +1248,7 @@ async def wait_for( *, check: Optional[Callable[[Interaction[Self], Union[Command[Any, ..., Any], ContextMenu]], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Interaction[Self], Union[Command[Any, ..., Any], ContextMenu]]: - ... + ) -> Tuple[Interaction[Self], Union[Command[Any, ..., Any], ContextMenu]]: ... # AutoMod @@ -1262,8 +1260,7 @@ async def wait_for( *, check: Optional[Callable[[AutoModRule], bool]] = ..., timeout: Optional[float] = ..., - ) -> AutoModRule: - ... + ) -> AutoModRule: ... @overload async def wait_for( @@ -1273,8 +1270,7 @@ async def wait_for( *, check: Optional[Callable[[AutoModAction], bool]] = ..., timeout: Optional[float] = ..., - ) -> AutoModAction: - ... + ) -> AutoModAction: ... # Channels @@ -1286,8 +1282,7 @@ async def wait_for( *, check: Optional[Callable[[GroupChannel, GroupChannel], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[GroupChannel, GroupChannel]: - ... + ) -> Tuple[GroupChannel, GroupChannel]: ... @overload async def wait_for( @@ -1297,8 +1292,7 @@ async def wait_for( *, check: Optional[Callable[[PrivateChannel, datetime.datetime], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[PrivateChannel, datetime.datetime]: - ... + ) -> Tuple[PrivateChannel, datetime.datetime]: ... @overload async def wait_for( @@ -1308,8 +1302,7 @@ async def wait_for( *, check: Optional[Callable[[GuildChannel], bool]] = ..., timeout: Optional[float] = ..., - ) -> GuildChannel: - ... + ) -> GuildChannel: ... @overload async def wait_for( @@ -1319,8 +1312,7 @@ async def wait_for( *, check: Optional[Callable[[GuildChannel, GuildChannel], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[GuildChannel, GuildChannel]: - ... + ) -> Tuple[GuildChannel, GuildChannel]: ... @overload async def wait_for( @@ -1335,8 +1327,7 @@ async def wait_for( ] ], timeout: Optional[float] = ..., - ) -> Tuple[Union[GuildChannel, Thread], Optional[datetime.datetime]]: - ... + ) -> Tuple[Union[GuildChannel, Thread], Optional[datetime.datetime]]: ... @overload async def wait_for( @@ -1346,8 +1337,7 @@ async def wait_for( *, check: Optional[Callable[[Messageable, Union[User, Member], datetime.datetime], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Messageable, Union[User, Member], datetime.datetime]: - ... + ) -> Tuple[Messageable, Union[User, Member], datetime.datetime]: ... @overload async def wait_for( @@ -1357,8 +1347,7 @@ async def wait_for( *, check: Optional[Callable[[RawTypingEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawTypingEvent: - ... + ) -> RawTypingEvent: ... # Debug & Gateway events @@ -1370,8 +1359,7 @@ async def wait_for( *, check: Optional[Callable[[], bool]] = ..., timeout: Optional[float] = ..., - ) -> None: - ... + ) -> None: ... @overload async def wait_for( @@ -1381,8 +1369,7 @@ async def wait_for( *, check: Optional[Callable[[int], bool]] = ..., timeout: Optional[float] = ..., - ) -> int: - ... + ) -> int: ... @overload async def wait_for( @@ -1392,8 +1379,7 @@ async def wait_for( *, check: Optional[Callable[[str], bool]] = ..., timeout: Optional[float] = ..., - ) -> str: - ... + ) -> str: ... @overload async def wait_for( @@ -1403,8 +1389,7 @@ async def wait_for( *, check: Optional[Callable[[Union[str, bytes]], bool]] = ..., timeout: Optional[float] = ..., - ) -> Union[str, bytes]: - ... + ) -> Union[str, bytes]: ... # Entitlements @overload @@ -1415,8 +1400,7 @@ async def wait_for( *, check: Optional[Callable[[Entitlement], bool]] = ..., timeout: Optional[float] = ..., - ) -> Entitlement: - ... + ) -> Entitlement: ... # Guilds @@ -1433,8 +1417,7 @@ async def wait_for( *, check: Optional[Callable[[Guild], bool]] = ..., timeout: Optional[float] = ..., - ) -> Guild: - ... + ) -> Guild: ... @overload async def wait_for( @@ -1444,8 +1427,7 @@ async def wait_for( *, check: Optional[Callable[[Guild, Guild], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Guild, Guild]: - ... + ) -> Tuple[Guild, Guild]: ... @overload async def wait_for( @@ -1455,8 +1437,7 @@ async def wait_for( *, check: Optional[Callable[[Guild, Sequence[Emoji], Sequence[Emoji]], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Guild, Sequence[Emoji], Sequence[Emoji]]: - ... + ) -> Tuple[Guild, Sequence[Emoji], Sequence[Emoji]]: ... @overload async def wait_for( @@ -1466,8 +1447,7 @@ async def wait_for( *, check: Optional[Callable[[Guild, Sequence[GuildSticker], Sequence[GuildSticker]], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Guild, Sequence[GuildSticker], Sequence[GuildSticker]]: - ... + ) -> Tuple[Guild, Sequence[GuildSticker], Sequence[GuildSticker]]: ... @overload async def wait_for( @@ -1477,8 +1457,7 @@ async def wait_for( *, check: Optional[Callable[[Invite], bool]] = ..., timeout: Optional[float] = ..., - ) -> Invite: - ... + ) -> Invite: ... @overload async def wait_for( @@ -1488,8 +1467,7 @@ async def wait_for( *, check: Optional[Callable[[AuditLogEntry], bool]] = ..., timeout: Optional[float] = ..., - ) -> AuditLogEntry: - ... + ) -> AuditLogEntry: ... # Integrations @@ -1501,8 +1479,7 @@ async def wait_for( *, check: Optional[Callable[[Integration], bool]] = ..., timeout: Optional[float] = ..., - ) -> Integration: - ... + ) -> Integration: ... @overload async def wait_for( @@ -1512,8 +1489,7 @@ async def wait_for( *, check: Optional[Callable[[Guild], bool]] = ..., timeout: Optional[float] = ..., - ) -> Guild: - ... + ) -> Guild: ... @overload async def wait_for( @@ -1523,8 +1499,7 @@ async def wait_for( *, check: Optional[Callable[[GuildChannel], bool]] = ..., timeout: Optional[float] = ..., - ) -> GuildChannel: - ... + ) -> GuildChannel: ... @overload async def wait_for( @@ -1534,8 +1509,7 @@ async def wait_for( *, check: Optional[Callable[[RawIntegrationDeleteEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawIntegrationDeleteEvent: - ... + ) -> RawIntegrationDeleteEvent: ... # Interactions @@ -1547,8 +1521,7 @@ async def wait_for( *, check: Optional[Callable[[Interaction[Self]], bool]] = ..., timeout: Optional[float] = ..., - ) -> Interaction[Self]: - ... + ) -> Interaction[Self]: ... # Members @@ -1560,8 +1533,7 @@ async def wait_for( *, check: Optional[Callable[[Member], bool]] = ..., timeout: Optional[float] = ..., - ) -> Member: - ... + ) -> Member: ... @overload async def wait_for( @@ -1571,8 +1543,7 @@ async def wait_for( *, check: Optional[Callable[[RawMemberRemoveEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawMemberRemoveEvent: - ... + ) -> RawMemberRemoveEvent: ... @overload async def wait_for( @@ -1582,8 +1553,7 @@ async def wait_for( *, check: Optional[Callable[[Member, Member], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Member, Member]: - ... + ) -> Tuple[Member, Member]: ... @overload async def wait_for( @@ -1593,8 +1563,7 @@ async def wait_for( *, check: Optional[Callable[[User, User], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[User, User]: - ... + ) -> Tuple[User, User]: ... @overload async def wait_for( @@ -1604,8 +1573,7 @@ async def wait_for( *, check: Optional[Callable[[Guild, Union[User, Member]], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Guild, Union[User, Member]]: - ... + ) -> Tuple[Guild, Union[User, Member]]: ... @overload async def wait_for( @@ -1615,8 +1583,7 @@ async def wait_for( *, check: Optional[Callable[[Guild, User], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Guild, User]: - ... + ) -> Tuple[Guild, User]: ... # Messages @@ -1628,8 +1595,7 @@ async def wait_for( *, check: Optional[Callable[[Message], bool]] = ..., timeout: Optional[float] = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def wait_for( @@ -1639,8 +1605,7 @@ async def wait_for( *, check: Optional[Callable[[Message, Message], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Message, Message]: - ... + ) -> Tuple[Message, Message]: ... @overload async def wait_for( @@ -1650,8 +1615,7 @@ async def wait_for( *, check: Optional[Callable[[List[Message]], bool]] = ..., timeout: Optional[float] = ..., - ) -> List[Message]: - ... + ) -> List[Message]: ... @overload async def wait_for( @@ -1661,8 +1625,7 @@ async def wait_for( *, check: Optional[Callable[[RawMessageUpdateEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawMessageUpdateEvent: - ... + ) -> RawMessageUpdateEvent: ... @overload async def wait_for( @@ -1672,8 +1635,7 @@ async def wait_for( *, check: Optional[Callable[[RawMessageDeleteEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawMessageDeleteEvent: - ... + ) -> RawMessageDeleteEvent: ... @overload async def wait_for( @@ -1683,8 +1645,7 @@ async def wait_for( *, check: Optional[Callable[[RawBulkMessageDeleteEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawBulkMessageDeleteEvent: - ... + ) -> RawBulkMessageDeleteEvent: ... # Reactions @@ -1696,8 +1657,7 @@ async def wait_for( *, check: Optional[Callable[[Reaction, Union[Member, User]], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Reaction, Union[Member, User]]: - ... + ) -> Tuple[Reaction, Union[Member, User]]: ... @overload async def wait_for( @@ -1707,8 +1667,7 @@ async def wait_for( *, check: Optional[Callable[[Message, List[Reaction]], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Message, List[Reaction]]: - ... + ) -> Tuple[Message, List[Reaction]]: ... @overload async def wait_for( @@ -1718,8 +1677,7 @@ async def wait_for( *, check: Optional[Callable[[Reaction], bool]] = ..., timeout: Optional[float] = ..., - ) -> Reaction: - ... + ) -> Reaction: ... @overload async def wait_for( @@ -1729,8 +1687,7 @@ async def wait_for( *, check: Optional[Callable[[RawReactionActionEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawReactionActionEvent: - ... + ) -> RawReactionActionEvent: ... @overload async def wait_for( @@ -1740,8 +1697,7 @@ async def wait_for( *, check: Optional[Callable[[RawReactionClearEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawReactionClearEvent: - ... + ) -> RawReactionClearEvent: ... @overload async def wait_for( @@ -1751,8 +1707,7 @@ async def wait_for( *, check: Optional[Callable[[RawReactionClearEmojiEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawReactionClearEmojiEvent: - ... + ) -> RawReactionClearEmojiEvent: ... # Roles @@ -1764,8 +1719,7 @@ async def wait_for( *, check: Optional[Callable[[Role], bool]] = ..., timeout: Optional[float] = ..., - ) -> Role: - ... + ) -> Role: ... @overload async def wait_for( @@ -1775,8 +1729,7 @@ async def wait_for( *, check: Optional[Callable[[Role, Role], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Role, Role]: - ... + ) -> Tuple[Role, Role]: ... # Scheduled Events @@ -1788,8 +1741,7 @@ async def wait_for( *, check: Optional[Callable[[ScheduledEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> ScheduledEvent: - ... + ) -> ScheduledEvent: ... @overload async def wait_for( @@ -1799,8 +1751,7 @@ async def wait_for( *, check: Optional[Callable[[ScheduledEvent, User], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[ScheduledEvent, User]: - ... + ) -> Tuple[ScheduledEvent, User]: ... # Stages @@ -1812,8 +1763,7 @@ async def wait_for( *, check: Optional[Callable[[StageInstance], bool]] = ..., timeout: Optional[float] = ..., - ) -> StageInstance: - ... + ) -> StageInstance: ... @overload async def wait_for( @@ -1823,8 +1773,7 @@ async def wait_for( *, check: Optional[Callable[[StageInstance, StageInstance], bool]] = ..., timeout: Optional[float] = ..., - ) -> Coroutine[Any, Any, Tuple[StageInstance, StageInstance]]: - ... + ) -> Coroutine[Any, Any, Tuple[StageInstance, StageInstance]]: ... # Subscriptions @overload @@ -1835,8 +1784,7 @@ async def wait_for( *, check: Optional[Callable[[Subscription], bool]] = ..., timeout: Optional[float] = ..., - ) -> Subscription: - ... + ) -> Subscription: ... # Threads @overload @@ -1847,8 +1795,7 @@ async def wait_for( *, check: Optional[Callable[[Thread], bool]] = ..., timeout: Optional[float] = ..., - ) -> Thread: - ... + ) -> Thread: ... @overload async def wait_for( @@ -1858,8 +1805,7 @@ async def wait_for( *, check: Optional[Callable[[Thread, Thread], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Thread, Thread]: - ... + ) -> Tuple[Thread, Thread]: ... @overload async def wait_for( @@ -1869,8 +1815,7 @@ async def wait_for( *, check: Optional[Callable[[RawThreadUpdateEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawThreadUpdateEvent: - ... + ) -> RawThreadUpdateEvent: ... @overload async def wait_for( @@ -1880,8 +1825,7 @@ async def wait_for( *, check: Optional[Callable[[RawThreadDeleteEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawThreadDeleteEvent: - ... + ) -> RawThreadDeleteEvent: ... @overload async def wait_for( @@ -1891,8 +1835,7 @@ async def wait_for( *, check: Optional[Callable[[ThreadMember], bool]] = ..., timeout: Optional[float] = ..., - ) -> ThreadMember: - ... + ) -> ThreadMember: ... @overload async def wait_for( @@ -1902,8 +1845,7 @@ async def wait_for( *, check: Optional[Callable[[RawThreadMembersUpdate], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawThreadMembersUpdate: - ... + ) -> RawThreadMembersUpdate: ... # Voice @@ -1915,8 +1857,7 @@ async def wait_for( *, check: Optional[Callable[[Member, VoiceState, VoiceState], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Member, VoiceState, VoiceState]: - ... + ) -> Tuple[Member, VoiceState, VoiceState]: ... # Polls @@ -1928,8 +1869,7 @@ async def wait_for( *, check: Optional[Callable[[Union[User, Member], PollAnswer], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Union[User, Member], PollAnswer]: - ... + ) -> Tuple[Union[User, Member], PollAnswer]: ... @overload async def wait_for( @@ -1939,32 +1879,29 @@ async def wait_for( *, check: Optional[Callable[[RawPollVoteActionEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawPollVoteActionEvent: - ... + ) -> RawPollVoteActionEvent: ... # Commands @overload async def wait_for( self: Union[Bot, AutoShardedBot], - event: Literal["command", "command_completion"], + event: Literal['command', 'command_completion'], /, *, check: Optional[Callable[[Context[Any]], bool]] = ..., timeout: Optional[float] = ..., - ) -> Context[Any]: - ... + ) -> Context[Any]: ... @overload async def wait_for( self: Union[Bot, AutoShardedBot], - event: Literal["command_error"], + event: Literal['command_error'], /, *, check: Optional[Callable[[Context[Any], CommandError], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Context[Any], CommandError]: - ... + ) -> Tuple[Context[Any], CommandError]: ... @overload async def wait_for( @@ -1974,8 +1911,7 @@ async def wait_for( *, check: Optional[Callable[..., bool]] = ..., timeout: Optional[float] = ..., - ) -> Any: - ... + ) -> Any: ... def wait_for( self, diff --git a/discord/embeds.py b/discord/embeds.py index f55c7cac1346..b1c98e66b330 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -189,7 +189,6 @@ def __init__( description: Optional[Any] = None, timestamp: Optional[datetime.datetime] = None, ): - self.colour = colour if colour is not None else color self.title: Optional[str] = title self.type: EmbedType = type @@ -362,7 +361,7 @@ def timestamp(self, value: Optional[datetime.datetime]) -> None: elif value is None: self._timestamp = None else: - raise TypeError(f"Expected datetime.datetime or None received {value.__class__.__name__} instead") + raise TypeError(f'Expected datetime.datetime or None received {value.__class__.__name__} instead') @property def footer(self) -> _EmbedFooterProxy: diff --git a/discord/enums.py b/discord/enums.py index 0b66158cb05f..4fe07ffce986 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -176,7 +176,7 @@ def __call__(cls, value: str) -> Any: try: return cls._enum_value_map_[value] except (KeyError, TypeError): - raise ValueError(f"{value!r} is not a valid {cls.__name__}") + raise ValueError(f'{value!r} is not a valid {cls.__name__}') def __getitem__(cls, key: str) -> Any: return cls._enum_member_map_[key] diff --git a/discord/errors.py b/discord/errors.py index a40842578d0d..c07a7ed152ff 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -261,7 +261,7 @@ def __init__(self, shard_id: Optional[int]): msg = ( 'Shard ID %s is requesting privileged intents that have not been explicitly enabled in the ' 'developer portal. It is recommended to go to https://discord.com/developers/applications/ ' - 'and explicitly enable the privileged intents within your application\'s page. If this is not ' + "and explicitly enable the privileged intents within your application's page. If this is not " 'possible, then consider disabling the privileged intents instead.' ) super().__init__(msg % shard_id) diff --git a/discord/ext/commands/_types.py b/discord/ext/commands/_types.py index 1331c9f3b3a1..d7801939c5d2 100644 --- a/discord/ext/commands/_types.py +++ b/discord/ext/commands/_types.py @@ -22,7 +22,6 @@ DEALINGS IN THE SOFTWARE. """ - from typing import Any, Awaitable, Callable, Coroutine, TYPE_CHECKING, Protocol, TypeVar, Union, Tuple, Optional @@ -49,9 +48,9 @@ MaybeAwaitable = Union[T, Awaitable[T]] CogT = TypeVar('CogT', bound='Optional[Cog]') -UserCheck = Callable[["ContextT"], MaybeCoro[bool]] -Hook = Union[Callable[["CogT", "ContextT"], Coro[Any]], Callable[["ContextT"], Coro[Any]]] -Error = Union[Callable[["CogT", "ContextT", "CommandError"], Coro[Any]], Callable[["ContextT", "CommandError"], Coro[Any]]] +UserCheck = Callable[['ContextT'], MaybeCoro[bool]] +Hook = Union[Callable[['CogT', 'ContextT'], Coro[Any]], Callable[['ContextT'], Coro[Any]]] +Error = Union[Callable[['CogT', 'ContextT', 'CommandError'], Coro[Any]], Callable[['ContextT', 'CommandError'], Coro[Any]]] ContextT = TypeVar('ContextT', bound='Context[Any]') BotT = TypeVar('BotT', bound=_Bot, covariant=True) @@ -60,11 +59,9 @@ class Check(Protocol[ContextT_co]): # type: ignore # TypeVar is expected to be invariant - predicate: Callable[[ContextT_co], Coroutine[Any, Any, bool]] - def __call__(self, coro_or_commands: T) -> T: - ... + def __call__(self, coro_or_commands: T) -> T: ... # This is merely a tag type to avoid circular import issues. diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 29b1f045e667..3a916d69e0b4 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -94,8 +94,7 @@ class _BotOptions(_ClientOptions, total=False): strip_after_prefix: bool case_insensitive: bool - class _AutoShardedBotOptions(_AutoShardedClientOptions, _BotOptions): - ... + class _AutoShardedBotOptions(_AutoShardedClientOptions, _BotOptions): ... __all__ = ( @@ -1233,8 +1232,8 @@ async def get_prefix(self, message: Message, /) -> Union[List[str], str]: raise raise TypeError( - "command_prefix must be plain string, iterable of strings, or callable " - f"returning either of these, not {ret.__class__.__name__}" + 'command_prefix must be plain string, iterable of strings, or callable ' + f'returning either of these, not {ret.__class__.__name__}' ) return ret @@ -1254,8 +1253,7 @@ async def get_context( /, *, cls: Type[ContextT], - ) -> ContextT: - ... + ) -> ContextT: ... async def get_context( self, @@ -1332,15 +1330,15 @@ class be provided, it must be similar enough to :class:`.Context`\'s except TypeError: if not isinstance(prefix, list): raise TypeError( - "get_prefix must return either a string or a list of string, " f"not {prefix.__class__.__name__}" + f'get_prefix must return either a string or a list of string, not {prefix.__class__.__name__}' ) # It's possible a bad command_prefix got us here. for value in prefix: if not isinstance(value, str): raise TypeError( - "Iterable command_prefix or list returned from get_prefix must " - f"contain only strings, not {value.__class__.__name__}" + 'Iterable command_prefix or list returned from get_prefix must ' + f'contain only strings, not {value.__class__.__name__}' ) # Getting here shouldn't happen @@ -1552,5 +1550,4 @@ def __init__( allowed_installs: app_commands.AppInstallationType = MISSING, intents: discord.Intents, **kwargs: Unpack[_AutoShardedBotOptions], - ) -> None: - ... + ) -> None: ... diff --git a/discord/ext/commands/cog.py b/discord/ext/commands/cog.py index 1b8a24e66be9..371a9f8c1047 100644 --- a/discord/ext/commands/cog.py +++ b/discord/ext/commands/cog.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations import inspect diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index 4171a82c65ba..968fec419130 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations import re @@ -70,7 +71,7 @@ T = TypeVar('T') -CogT = TypeVar('CogT', bound="Cog") +CogT = TypeVar('CogT', bound='Cog') if TYPE_CHECKING: P = ParamSpec('P') @@ -424,8 +425,8 @@ def clean_prefix(self) -> str: # consider this to be an *incredibly* strange use case. I'd rather go # for this common use case rather than waste performance for the # odd one. - pattern = re.compile(r"<@!?%s>" % user.id) - return pattern.sub("@%s" % user.display_name.replace('\\', r'\\'), self.prefix) + pattern = re.compile(r'<@!?%s>' % user.id) + return pattern.sub('@%s' % user.display_name.replace('\\', r'\\'), self.prefix) @property def cog(self) -> Optional[Cog]: @@ -642,8 +643,7 @@ async def reply( suppress_embeds: bool = ..., ephemeral: bool = ..., silent: bool = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def reply( @@ -659,8 +659,7 @@ async def reply( suppress_embeds: bool = ..., ephemeral: bool = ..., silent: bool = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def reply( @@ -681,8 +680,7 @@ async def reply( ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def reply( @@ -703,8 +701,7 @@ async def reply( ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def reply( @@ -725,8 +722,7 @@ async def reply( ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def reply( @@ -747,8 +743,7 @@ async def reply( ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... async def reply(self, content: Optional[str] = None, **kwargs: Any) -> Message: """|coro| @@ -865,8 +860,7 @@ async def send( suppress_embeds: bool = ..., ephemeral: bool = ..., silent: bool = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def send( @@ -882,8 +876,7 @@ async def send( suppress_embeds: bool = ..., ephemeral: bool = ..., silent: bool = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def send( @@ -904,8 +897,7 @@ async def send( ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def send( @@ -926,8 +918,7 @@ async def send( ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def send( @@ -948,8 +939,7 @@ async def send( ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def send( @@ -970,8 +960,7 @@ async def send( ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... async def send( self, diff --git a/discord/ext/commands/converter.py b/discord/ext/commands/converter.py index d316f6ccc63c..baf22c6263bc 100644 --- a/discord/ext/commands/converter.py +++ b/discord/ext/commands/converter.py @@ -1347,13 +1347,11 @@ async def _actual_conversion(ctx: Context[BotT], converter: Any, argument: str, @overload async def run_converters( ctx: Context[BotT], converter: Union[Type[Converter[T]], Converter[T]], argument: str, param: Parameter -) -> T: - ... +) -> T: ... @overload -async def run_converters(ctx: Context[BotT], converter: Any, argument: str, param: Parameter) -> Any: - ... +async def run_converters(ctx: Context[BotT], converter: Any, argument: str, param: Parameter) -> Any: ... async def run_converters(ctx: Context[BotT], converter: Any, argument: str, param: Parameter) -> Any: diff --git a/discord/ext/commands/cooldowns.py b/discord/ext/commands/cooldowns.py index cf328d9b3f5e..fb68944bde66 100644 --- a/discord/ext/commands/cooldowns.py +++ b/discord/ext/commands/cooldowns.py @@ -242,10 +242,10 @@ def __init__(self, number: int, *, per: BucketType, wait: bool) -> None: self.wait: bool = wait if number <= 0: - raise ValueError('max_concurrency \'number\' cannot be less than 1') + raise ValueError("max_concurrency 'number' cannot be less than 1") if not isinstance(per, BucketType): - raise TypeError(f'max_concurrency \'per\' must be of type BucketType not {type(per)!r}') + raise TypeError(f"max_concurrency 'per' must be of type BucketType not {type(per)!r}") def copy(self) -> Self: return self.__class__(self.number, per=self.per, wait=self.wait) diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index e4724508b86b..9ec0dd484438 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations import asyncio @@ -398,6 +399,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): .. versionadded:: 2.0 """ + __original_kwargs__: Dict[str, Any] def __new__(cls, *args: Any, **kwargs: Any) -> Self: @@ -451,7 +453,7 @@ def __init__( self.extras: Dict[Any, Any] = kwargs.get('extras', {}) if not isinstance(self.aliases, (list, tuple)): - raise TypeError("Aliases of a command must be a list or a tuple of strings.") + raise TypeError('Aliases of a command must be a list or a tuple of strings.') self.description: str = inspect.cleandoc(kwargs.get('description', '')) self.hidden: bool = kwargs.get('hidden', False) @@ -474,7 +476,7 @@ def __init__( elif isinstance(cooldown, CooldownMapping): buckets: CooldownMapping[Context[Any]] = cooldown else: - raise TypeError("Cooldown must be an instance of CooldownMapping or None.") + raise TypeError('Cooldown must be an instance of CooldownMapping or None.') self._buckets: CooldownMapping[Context[Any]] = buckets try: @@ -520,7 +522,10 @@ def cog(self, value: CogT) -> None: @property def callback( self, - ) -> Union[Callable[Concatenate[CogT, Context[Any], P], Coro[T]], Callable[Concatenate[Context[Any], P], Coro[T]],]: + ) -> Union[ + Callable[Concatenate[CogT, Context[Any], P], Coro[T]], + Callable[Concatenate[Context[Any], P], Coro[T]], + ]: return self._callback @callback.setter @@ -1507,8 +1512,7 @@ def command( ] ], Command[CogT, P, T], - ]: - ... + ]: ... @overload def command( @@ -1525,8 +1529,7 @@ def command( ] ], CommandT, - ]: - ... + ]: ... def command( self, @@ -1566,8 +1569,7 @@ def group( ] ], Group[CogT, P, T], - ]: - ... + ]: ... @overload def group( @@ -1584,8 +1586,7 @@ def group( ] ], GroupT, - ]: - ... + ]: ... def group( self, @@ -1731,35 +1732,28 @@ async def reinvoke(self, ctx: Context[BotT], /, *, call_hooks: bool = False) -> class _CommandDecorator: @overload - def __call__(self, func: Callable[Concatenate[CogT, ContextT, P], Coro[T]], /) -> Command[CogT, P, T]: - ... + def __call__(self, func: Callable[Concatenate[CogT, ContextT, P], Coro[T]], /) -> Command[CogT, P, T]: ... @overload - def __call__(self, func: Callable[Concatenate[ContextT, P], Coro[T]], /) -> Command[None, P, T]: - ... + def __call__(self, func: Callable[Concatenate[ContextT, P], Coro[T]], /) -> Command[None, P, T]: ... - def __call__(self, func: Callable[..., Coro[T]], /) -> Any: - ... + def __call__(self, func: Callable[..., Coro[T]], /) -> Any: ... class _GroupDecorator: @overload - def __call__(self, func: Callable[Concatenate[CogT, ContextT, P], Coro[T]], /) -> Group[CogT, P, T]: - ... + def __call__(self, func: Callable[Concatenate[CogT, ContextT, P], Coro[T]], /) -> Group[CogT, P, T]: ... @overload - def __call__(self, func: Callable[Concatenate[ContextT, P], Coro[T]], /) -> Group[None, P, T]: - ... + def __call__(self, func: Callable[Concatenate[ContextT, P], Coro[T]], /) -> Group[None, P, T]: ... - def __call__(self, func: Callable[..., Coro[T]], /) -> Any: - ... + def __call__(self, func: Callable[..., Coro[T]], /) -> Any: ... @overload def command( name: str = ..., **attrs: Unpack[_CommandDecoratorKwargs], -) -> _CommandDecorator: - ... +) -> _CommandDecorator: ... @overload @@ -1775,8 +1769,7 @@ def command( ] ], CommandT, -]: - ... +]: ... def command( @@ -1828,8 +1821,7 @@ def decorator(func): def group( name: str = ..., **attrs: Unpack[_GroupDecoratorKwargs], -) -> _GroupDecorator: - ... +) -> _GroupDecorator: ... @overload @@ -1845,8 +1837,7 @@ def group( ] ], GroupT, -]: - ... +]: ... def group( @@ -2226,7 +2217,7 @@ async def test(ctx): invalid = set(perms) - set(discord.Permissions.VALID_FLAGS) if invalid: - raise TypeError(f"Invalid permission(s): {', '.join(invalid)}") + raise TypeError(f'Invalid permission(s): {", ".join(invalid)}') def predicate(ctx: Context[BotT]) -> bool: permissions = ctx.permissions @@ -2251,7 +2242,7 @@ def bot_has_permissions(**perms: Unpack[_PermissionsKwargs]) -> Check[Any]: invalid = set(perms) - set(discord.Permissions.VALID_FLAGS) if invalid: - raise TypeError(f"Invalid permission(s): {', '.join(invalid)}") + raise TypeError(f'Invalid permission(s): {", ".join(invalid)}') def predicate(ctx: Context[BotT]) -> bool: permissions = ctx.bot_permissions @@ -2278,7 +2269,7 @@ def has_guild_permissions(**perms: Unpack[_PermissionsKwargs]) -> Check[Any]: invalid = set(perms) - set(discord.Permissions.VALID_FLAGS) if invalid: - raise TypeError(f"Invalid permission(s): {', '.join(invalid)}") + raise TypeError(f'Invalid permission(s): {", ".join(invalid)}') def predicate(ctx: Context[BotT]) -> bool: if not ctx.guild: @@ -2304,7 +2295,7 @@ def bot_has_guild_permissions(**perms: Unpack[_PermissionsKwargs]) -> Check[Any] invalid = set(perms) - set(discord.Permissions.VALID_FLAGS) if invalid: - raise TypeError(f"Invalid permission(s): {', '.join(invalid)}") + raise TypeError(f'Invalid permission(s): {", ".join(invalid)}') def predicate(ctx: Context[BotT]) -> bool: if not ctx.guild: @@ -2544,7 +2535,7 @@ def dynamic_cooldown( The type of cooldown to have. """ if not callable(cooldown): - raise TypeError("A callable must be provided") + raise TypeError('A callable must be provided') if type is BucketType.default: raise ValueError('BucketType.default cannot be used in dynamic cooldowns') diff --git a/discord/ext/commands/errors.py b/discord/ext/commands/errors.py index feb4aee279cd..a962a4e73c3b 100644 --- a/discord/ext/commands/errors.py +++ b/discord/ext/commands/errors.py @@ -925,7 +925,7 @@ class BadLiteralArgument(UserInputError): .. versionadded:: 2.3 """ - def __init__(self, param: Parameter, literals: Tuple[Any, ...], errors: List[CommandError], argument: str = "") -> None: + def __init__(self, param: Parameter, literals: Tuple[Any, ...], errors: List[CommandError], argument: str = '') -> None: self.param: Parameter = param self.literals: Tuple[Any, ...] = literals self.errors: List[CommandError] = errors diff --git a/discord/ext/commands/flags.py b/discord/ext/commands/flags.py index 0766ecae34ea..0b03b81d4447 100644 --- a/discord/ext/commands/flags.py +++ b/discord/ext/commands/flags.py @@ -197,7 +197,7 @@ def get_flags(namespace: Dict[str, Any], globals: Dict[str, Any], locals: Dict[s if flag.positional: if positional is not None: - raise TypeError(f"{flag.name!r} positional flag conflicts with {positional.name!r} flag.") + raise TypeError(f'{flag.name!r} positional flag conflicts with {positional.name!r} flag.') positional = flag annotation = flag.annotation = resolve_annotation(flag.annotation, globals, locals, cache) diff --git a/discord/ext/commands/help.py b/discord/ext/commands/help.py index 90b44d16a674..dabbd9ef9c8d 100644 --- a/discord/ext/commands/help.py +++ b/discord/ext/commands/help.py @@ -1072,7 +1072,7 @@ def __init__(self, **options: Unpack[_DefaultHelpCommandOptions]) -> None: self.sort_commands: bool = options.pop('sort_commands', True) self.dm_help: bool = options.pop('dm_help', False) self.dm_help_threshold: int = options.pop('dm_help_threshold', 1000) - self.arguments_heading: str = options.pop('arguments_heading', "Arguments:") + self.arguments_heading: str = options.pop('arguments_heading', 'Arguments:') self.commands_heading: str = options.pop('commands_heading', 'Commands:') self.default_argument_description: str = options.pop('default_argument_description', 'No description given') self.no_category: str = options.pop('no_category', 'No Category') diff --git a/discord/ext/commands/parameters.py b/discord/ext/commands/parameters.py index 2640902a34b5..2d0de4f6501c 100644 --- a/discord/ext/commands/parameters.py +++ b/discord/ext/commands/parameters.py @@ -278,8 +278,7 @@ def __call__( description: str = empty, displayed_default: str = empty, displayed_name: str = empty, - ) -> Any: - ... + ) -> Any: ... param: ParameterAlias = parameter diff --git a/discord/ext/commands/view.py b/discord/ext/commands/view.py index e287221eb9a2..53ca9d8b2c39 100644 --- a/discord/ext/commands/view.py +++ b/discord/ext/commands/view.py @@ -31,22 +31,22 @@ # map from opening quotes to closing quotes _quotes = { '"': '"', - "‘": "’", - "‚": "‛", - "“": "”", - "„": "‟", - "⹂": "⹂", - "「": "」", - "『": "』", - "〝": "〞", - "﹁": "﹂", - "﹃": "﹄", - """: """, - "「": "」", - "«": "»", - "‹": "›", - "《": "》", - "〈": "〉", + '‘': '’', + '‚': '‛', + '“': '”', + '„': '‟', + '⹂': '⹂', + '「': '」', + '『': '』', + '〝': '〞', + '﹁': '﹂', + '﹃': '﹄', + '"': '"', + '「': '」', + '«': '»', + '‹': '›', + '《': '》', + '〈': '〉', } _all_quotes = set(_quotes.keys()) | set(_quotes.values()) diff --git a/discord/flags.py b/discord/flags.py index dd13ed560b6c..5105a4156c30 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -112,12 +112,10 @@ def __init__(self, func: Callable[[Any], int]): self.__doc__: Optional[str] = func.__doc__ @overload - def __get__(self, instance: None, owner: Type[BF]) -> Self: - ... + def __get__(self, instance: None, owner: Type[BF]) -> Self: ... @overload - def __get__(self, instance: BF, owner: Type[BF]) -> bool: - ... + def __get__(self, instance: BF, owner: Type[BF]) -> bool: ... def __get__(self, instance: Optional[BF], owner: Type[BF]) -> Any: if instance is None: diff --git a/discord/gateway.py b/discord/gateway.py index 50606efa95f2..4e1f78c68284 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -141,7 +141,7 @@ def __init__( self.shard_id: Optional[int] = shard_id self.msg: str = 'Keeping shard ID %s websocket alive with sequence %s.' self.block_msg: str = 'Shard ID %s heartbeat blocked for more than %s seconds.' - self.behind_msg: str = 'Can\'t keep up, shard ID %s websocket is %.1fs behind.' + self.behind_msg: str = "Can't keep up, shard ID %s websocket is %.1fs behind." self._stop_ev: threading.Event = threading.Event() self._last_ack: float = time.perf_counter() self._last_send: float = time.perf_counter() @@ -152,7 +152,7 @@ def __init__( def run(self) -> None: while not self._stop_ev.wait(self.interval): if self._last_recv + self.heartbeat_timeout < time.perf_counter(): - _log.warning("Shard ID %s has stopped responding to the gateway. Closing and restarting.", self.shard_id) + _log.warning('Shard ID %s has stopped responding to the gateway. Closing and restarting.', self.shard_id) coro = self.ws.close(4000) f = asyncio.run_coroutine_threadsafe(coro, loop=self.ws.loop) diff --git a/discord/guild.py b/discord/guild.py index 6e2a9e626ed9..47a8b57c05fa 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -219,8 +219,8 @@ class GuildPreview(Hashable): 'stickers', 'features', 'description', - "approximate_member_count", - "approximate_presence_count", + 'approximate_member_count', + 'approximate_presence_count', ) def __init__(self, *, data: GuildPreviewPayload, state: ConnectionState) -> None: @@ -1296,8 +1296,7 @@ def _create_channel( overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] = ..., category: Optional[Snowflake] = ..., **options: Any, - ) -> Coroutine[Any, Any, TextChannelPayload]: - ... + ) -> Coroutine[Any, Any, TextChannelPayload]: ... @overload def _create_channel( @@ -1307,8 +1306,7 @@ def _create_channel( overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] = ..., category: Optional[Snowflake] = ..., **options: Any, - ) -> Coroutine[Any, Any, VoiceChannelPayload]: - ... + ) -> Coroutine[Any, Any, VoiceChannelPayload]: ... @overload def _create_channel( @@ -1318,8 +1316,7 @@ def _create_channel( overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] = ..., category: Optional[Snowflake] = ..., **options: Any, - ) -> Coroutine[Any, Any, StageChannelPayload]: - ... + ) -> Coroutine[Any, Any, StageChannelPayload]: ... @overload def _create_channel( @@ -1329,8 +1326,7 @@ def _create_channel( overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] = ..., category: Optional[Snowflake] = ..., **options: Any, - ) -> Coroutine[Any, Any, CategoryChannelPayload]: - ... + ) -> Coroutine[Any, Any, CategoryChannelPayload]: ... @overload def _create_channel( @@ -1340,8 +1336,7 @@ def _create_channel( overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] = ..., category: Optional[Snowflake] = ..., **options: Any, - ) -> Coroutine[Any, Any, NewsChannelPayload]: - ... + ) -> Coroutine[Any, Any, NewsChannelPayload]: ... @overload def _create_channel( @@ -1351,8 +1346,7 @@ def _create_channel( overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] = ..., category: Optional[Snowflake] = ..., **options: Any, - ) -> Coroutine[Any, Any, Union[TextChannelPayload, NewsChannelPayload]]: - ... + ) -> Coroutine[Any, Any, Union[TextChannelPayload, NewsChannelPayload]]: ... @overload def _create_channel( @@ -1362,8 +1356,7 @@ def _create_channel( overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] = ..., category: Optional[Snowflake] = ..., **options: Any, - ) -> Coroutine[Any, Any, ForumChannelPayload]: - ... + ) -> Coroutine[Any, Any, ForumChannelPayload]: ... @overload def _create_channel( @@ -1373,8 +1366,7 @@ def _create_channel( overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] = ..., category: Optional[Snowflake] = ..., **options: Any, - ) -> Coroutine[Any, Any, GuildChannelPayload]: - ... + ) -> Coroutine[Any, Any, GuildChannelPayload]: ... def _create_channel( self, @@ -1965,7 +1957,9 @@ async def create_forum( ) channel = ForumChannel( - state=self._state, guild=self, data=data # pyright: ignore[reportArgumentType] # it's the correct data + state=self._state, + guild=self, + data=data, # pyright: ignore[reportArgumentType] # it's the correct data ) # temporarily add to the cache @@ -3246,8 +3240,7 @@ async def create_scheduled_event( description: str = ..., image: bytes = ..., reason: Optional[str] = ..., - ) -> ScheduledEvent: - ... + ) -> ScheduledEvent: ... @overload async def create_scheduled_event( @@ -3262,8 +3255,7 @@ async def create_scheduled_event( description: str = ..., image: bytes = ..., reason: Optional[str] = ..., - ) -> ScheduledEvent: - ... + ) -> ScheduledEvent: ... @overload async def create_scheduled_event( @@ -3277,8 +3269,7 @@ async def create_scheduled_event( description: str = ..., image: bytes = ..., reason: Optional[str] = ..., - ) -> ScheduledEvent: - ... + ) -> ScheduledEvent: ... @overload async def create_scheduled_event( @@ -3292,8 +3283,7 @@ async def create_scheduled_event( description: str = ..., image: bytes = ..., reason: Optional[str] = ..., - ) -> ScheduledEvent: - ... + ) -> ScheduledEvent: ... async def create_scheduled_event( self, @@ -3405,7 +3395,7 @@ async def create_scheduled_event( if entity_type is None: raise TypeError( - 'invalid GuildChannel type passed, must be VoiceChannel or StageChannel ' f'not {channel.__class__.__name__}' + f'invalid GuildChannel type passed, must be VoiceChannel or StageChannel not {channel.__class__.__name__}' ) if privacy_level is not MISSING: @@ -3660,8 +3650,7 @@ async def create_role( mentionable: bool = ..., secondary_colour: Optional[Union[Colour, int]] = ..., tertiary_colour: Optional[Union[Colour, int]] = ..., - ) -> Role: - ... + ) -> Role: ... @overload async def create_role( @@ -3676,8 +3665,7 @@ async def create_role( mentionable: bool = ..., secondary_color: Optional[Union[Colour, int]] = ..., tertiary_color: Optional[Union[Colour, int]] = ..., - ) -> Role: - ... + ) -> Role: ... async def create_role( self, diff --git a/discord/http.py b/discord/http.py index 9d1b85a50359..7b82fddb6e4b 100644 --- a/discord/http.py +++ b/discord/http.py @@ -2037,22 +2037,19 @@ def delete_stage_instance(self, channel_id: Snowflake, *, reason: Optional[str] @overload def get_scheduled_events( self, guild_id: Snowflake, with_user_count: Literal[True] - ) -> Response[List[scheduled_event.GuildScheduledEventWithUserCount]]: - ... + ) -> Response[List[scheduled_event.GuildScheduledEventWithUserCount]]: ... @overload def get_scheduled_events( self, guild_id: Snowflake, with_user_count: Literal[False] - ) -> Response[List[scheduled_event.GuildScheduledEvent]]: - ... + ) -> Response[List[scheduled_event.GuildScheduledEvent]]: ... @overload def get_scheduled_events( self, guild_id: Snowflake, with_user_count: bool ) -> Union[ Response[List[scheduled_event.GuildScheduledEventWithUserCount]], Response[List[scheduled_event.GuildScheduledEvent]] - ]: - ... + ]: ... def get_scheduled_events(self, guild_id: Snowflake, with_user_count: bool) -> Response[Any]: params = {'with_user_count': int(with_user_count)} @@ -2081,20 +2078,19 @@ def create_guild_scheduled_event( @overload def get_scheduled_event( self, guild_id: Snowflake, guild_scheduled_event_id: Snowflake, with_user_count: Literal[True] - ) -> Response[scheduled_event.GuildScheduledEventWithUserCount]: - ... + ) -> Response[scheduled_event.GuildScheduledEventWithUserCount]: ... @overload def get_scheduled_event( self, guild_id: Snowflake, guild_scheduled_event_id: Snowflake, with_user_count: Literal[False] - ) -> Response[scheduled_event.GuildScheduledEvent]: - ... + ) -> Response[scheduled_event.GuildScheduledEvent]: ... @overload def get_scheduled_event( self, guild_id: Snowflake, guild_scheduled_event_id: Snowflake, with_user_count: bool - ) -> Union[Response[scheduled_event.GuildScheduledEventWithUserCount], Response[scheduled_event.GuildScheduledEvent]]: - ... + ) -> Union[ + Response[scheduled_event.GuildScheduledEventWithUserCount], Response[scheduled_event.GuildScheduledEvent] + ]: ... def get_scheduled_event( self, guild_id: Snowflake, guild_scheduled_event_id: Snowflake, with_user_count: bool @@ -2164,8 +2160,7 @@ def get_scheduled_event_users( with_member: Literal[True], before: Optional[Snowflake] = ..., after: Optional[Snowflake] = ..., - ) -> Response[scheduled_event.ScheduledEventUsersWithMember]: - ... + ) -> Response[scheduled_event.ScheduledEventUsersWithMember]: ... @overload def get_scheduled_event_users( @@ -2176,8 +2171,7 @@ def get_scheduled_event_users( with_member: Literal[False], before: Optional[Snowflake] = ..., after: Optional[Snowflake] = ..., - ) -> Response[scheduled_event.ScheduledEventUsers]: - ... + ) -> Response[scheduled_event.ScheduledEventUsers]: ... @overload def get_scheduled_event_users( @@ -2188,8 +2182,7 @@ def get_scheduled_event_users( with_member: bool, before: Optional[Snowflake] = ..., after: Optional[Snowflake] = ..., - ) -> Union[Response[scheduled_event.ScheduledEventUsersWithMember], Response[scheduled_event.ScheduledEventUsers]]: - ... + ) -> Union[Response[scheduled_event.ScheduledEventUsersWithMember], Response[scheduled_event.ScheduledEventUsers]]: ... def get_scheduled_event_users( self, @@ -2572,7 +2565,6 @@ def edit_guild_onboarding( mode: Optional[onboarding.OnboardingMode] = None, reason: Optional[str], ) -> Response[onboarding.Onboarding]: - payload = {} if prompts is not None: diff --git a/discord/interactions.py b/discord/interactions.py index 3cbc4107d3f6..e295de0c69bd 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -95,8 +95,8 @@ GroupChannel, ] InteractionCallbackResource = Union[ - "InteractionMessage", - "InteractionCallbackActivityInstance", + 'InteractionMessage', + 'InteractionCallbackActivityInstance', ] MISSING: Any = utils.MISSING @@ -735,7 +735,6 @@ def _update(self, data: InteractionCallbackPayload) -> None: resource = data.get('resource') if resource is not None: - self.type = try_enum(InteractionResponseType, resource['type']) message = resource.get('message') @@ -916,8 +915,7 @@ async def send_message( suppress_embeds: bool = False, silent: bool = False, delete_after: Optional[float] = None, - ) -> InteractionCallbackResponse[ClientT]: - ... + ) -> InteractionCallbackResponse[ClientT]: ... @overload async def send_message( @@ -936,8 +934,7 @@ async def send_message( silent: bool = False, delete_after: Optional[float] = None, poll: Poll = MISSING, - ) -> InteractionCallbackResponse[ClientT]: - ... + ) -> InteractionCallbackResponse[ClientT]: ... async def send_message( self, @@ -1437,8 +1434,7 @@ async def edit( view: LayoutView, allowed_mentions: Optional[AllowedMentions] = None, delete_after: Optional[float] = None, - ) -> InteractionMessage: - ... + ) -> InteractionMessage: ... @overload async def edit( @@ -1452,8 +1448,7 @@ async def edit( allowed_mentions: Optional[AllowedMentions] = None, delete_after: Optional[float] = None, poll: Poll = MISSING, - ) -> InteractionMessage: - ... + ) -> InteractionMessage: ... async def edit( self, diff --git a/discord/invite.py b/discord/invite.py index 38ee45901d0d..8b5088a892bb 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -418,7 +418,7 @@ def __init__( target_user_data = data.get('target_user') self.target_user: Optional[User] = None if target_user_data is None else self._state.create_user(target_user_data) - self.target_type: InviteTarget = try_enum(InviteTarget, data.get("target_type", 0)) + self.target_type: InviteTarget = try_enum(InviteTarget, data.get('target_type', 0)) application = data.get('target_application') self.target_application: Optional[PartialAppInfo] = ( diff --git a/discord/message.py b/discord/message.py index d0af26124dbe..db2e0e448329 100644 --- a/discord/message.py +++ b/discord/message.py @@ -448,7 +448,7 @@ def __init__(self, parent: MessageReference): self._parent: MessageReference = parent def __repr__(self) -> str: - return f"" + return f'' @property def id(self) -> int: @@ -883,7 +883,9 @@ def __init__(self, *, state: ConnectionState, guild: Optional[Guild], data: Mess self.modal_interaction: Optional[MessageInteractionMetadata] = None try: self.modal_interaction = MessageInteractionMetadata( - state=state, guild=guild, data=data['triggering_interaction_metadata'] # type: ignore # EAFP + state=state, + guild=guild, + data=data['triggering_interaction_metadata'], # type: ignore # EAFP ) except KeyError: pass @@ -1748,8 +1750,7 @@ async def reply( mention_author: bool = ..., suppress_embeds: bool = ..., silent: bool = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def reply( @@ -1764,8 +1765,7 @@ async def reply( mention_author: bool = ..., suppress_embeds: bool = ..., silent: bool = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def reply( @@ -1785,8 +1785,7 @@ async def reply( suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def reply( @@ -1806,8 +1805,7 @@ async def reply( suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def reply( @@ -1827,8 +1825,7 @@ async def reply( suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def reply( @@ -1848,8 +1845,7 @@ async def reply( suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... async def reply(self, content: Optional[str] = None, **kwargs: Any) -> Message: """|coro| @@ -2726,19 +2722,19 @@ def system_content(self) -> str: if self.type is MessageType.new_member: formats = [ - "{0} joined the party.", - "{0} is here.", - "Welcome, {0}. We hope you brought pizza.", - "A wild {0} appeared.", - "{0} just landed.", - "{0} just slid into the server.", - "{0} just showed up!", - "Welcome {0}. Say hi!", - "{0} hopped into the server.", - "Everyone welcome {0}!", + '{0} joined the party.', + '{0} is here.', + 'Welcome, {0}. We hope you brought pizza.', + 'A wild {0} appeared.', + '{0} just landed.', + '{0} just slid into the server.', + '{0} just showed up!', + 'Welcome {0}. Say hi!', + '{0} hopped into the server.', + 'Everyone welcome {0}!', "Glad you're here, {0}.", - "Good to see you, {0}.", - "Yay you made it, {0}!", + 'Good to see you, {0}.', + 'Yay you made it, {0}!', ] created_at_ms = int(self.created_at.timestamp() * 1000) @@ -2797,7 +2793,7 @@ def system_content(self) -> str: if self.type is MessageType.thread_starter_message: if self.reference is None or self.reference.resolved is None: - return 'Sorry, we couldn\'t load the first message in this thread' + return "Sorry, we couldn't load the first message in this thread" # the resolved message for the reference will be a Message return self.reference.resolved.content # type: ignore @@ -2867,7 +2863,7 @@ def system_content(self) -> str: embed.fields, name='poll_question_text', ) - return f'{self.author.display_name}\'s poll {poll_title.value} has closed.' # type: ignore + return f"{self.author.display_name}'s poll {poll_title.value} has closed." # type: ignore # Fallback for unknown message types return '' diff --git a/discord/opus.py b/discord/opus.py index 971675f73ef6..3d996939bfa9 100644 --- a/discord/opus.py +++ b/discord/opus.py @@ -72,7 +72,7 @@ class SignalCtl(TypedDict): _log = logging.getLogger(__name__) -OPUS_SILENCE = b'\xF8\xFF\xFE' +OPUS_SILENCE = b'\xf8\xff\xfe' c_int_ptr = ctypes.POINTER(ctypes.c_int) c_int16_ptr = ctypes.POINTER(ctypes.c_int16) @@ -218,7 +218,7 @@ def libopus_loader(name: str) -> Any: if item[3]: func.errcheck = item[3] except KeyError: - _log.exception("Error assigning check function to %s", func) + _log.exception('Error assigning check function to %s', func) return lib @@ -476,16 +476,14 @@ def _get_last_packet_duration(self) -> int: return ret.value @overload - def decode(self, data: bytes, *, fec: bool) -> bytes: - ... + def decode(self, data: bytes, *, fec: bool) -> bytes: ... @overload - def decode(self, data: Literal[None], *, fec: Literal[False]) -> bytes: - ... + def decode(self, data: Literal[None], *, fec: Literal[False]) -> bytes: ... def decode(self, data: Optional[bytes], *, fec: bool = False) -> bytes: if data is None and fec: - raise TypeError("Invalid arguments: FEC cannot be used with null data") + raise TypeError('Invalid arguments: FEC cannot be used with null data') if data is None: frame_size = self._get_last_packet_duration() or self.SAMPLES_PER_FRAME diff --git a/discord/permissions.py b/discord/permissions.py index 93d09db27fec..9d3cac89ff11 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -94,11 +94,9 @@ class _BasePermissionsKwargs(Generic[BoolOrNoneT], TypedDict, total=False): create_polls: BoolOrNoneT use_external_apps: BoolOrNoneT - class _PermissionsKwargs(_BasePermissionsKwargs[bool]): - ... + class _PermissionsKwargs(_BasePermissionsKwargs[bool]): ... - class _PermissionOverwriteKwargs(_BasePermissionsKwargs[Optional[bool]]): - ... + class _PermissionOverwriteKwargs(_BasePermissionsKwargs[Optional[bool]]): ... # A permission alias works like a regular flag but is marked @@ -219,14 +217,14 @@ def is_subset(self, other: Permissions) -> bool: if isinstance(other, Permissions): return (self.value & other.value) == self.value else: - raise TypeError(f"cannot compare {self.__class__.__name__} with {other.__class__.__name__}") + raise TypeError(f'cannot compare {self.__class__.__name__} with {other.__class__.__name__}') def is_superset(self, other: Permissions) -> bool: """Returns ``True`` if self has the same or more permissions as other.""" if isinstance(other, Permissions): return (self.value | other.value) == self.value else: - raise TypeError(f"cannot compare {self.__class__.__name__} with {other.__class__.__name__}") + raise TypeError(f'cannot compare {self.__class__.__name__} with {other.__class__.__name__}') def is_strict_subset(self, other: Permissions) -> bool: """Returns ``True`` if the permissions on other are a strict subset of those on self.""" diff --git a/discord/player.py b/discord/player.py index bad6da88ed92..6243c0417972 100644 --- a/discord/player.py +++ b/discord/player.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations import threading @@ -163,7 +164,7 @@ def __init__( stderr: Optional[IO[bytes]] = subprocess_kwargs.pop('stderr', None) if stderr == subprocess.PIPE: - warnings.warn("Passing subprocess.PIPE does nothing", DeprecationWarning, stacklevel=3) + warnings.warn('Passing subprocess.PIPE does nothing', DeprecationWarning, stacklevel=3) stderr = None piping_stderr = False @@ -573,7 +574,7 @@ async def probe( if isinstance(method, str): probefunc = getattr(cls, '_probe_codec_' + method, None) if probefunc is None: - raise AttributeError(f"Invalid probe method {method!r}") + raise AttributeError(f'Invalid probe method {method!r}') if probefunc is cls._probe_codec_native: fallback = cls._probe_codec_fallback @@ -603,9 +604,9 @@ async def probe( except BaseException: _log.exception("Fallback probe using '%s' failed", executable) else: - _log.debug("Fallback probe found codec=%s, bitrate=%s", codec, bitrate) + _log.debug('Fallback probe found codec=%s, bitrate=%s', codec, bitrate) else: - _log.debug("Probe found codec=%s, bitrate=%s", codec, bitrate) + _log.debug('Probe found codec=%s, bitrate=%s', codec, bitrate) return codec, bitrate @@ -634,11 +635,11 @@ def _probe_codec_fallback(source, executable: str = 'ffmpeg') -> Tuple[Optional[ output = out.decode('utf8') codec = bitrate = None - codec_match = re.search(r"Stream #0.*?Audio: (\w+)", output) + codec_match = re.search(r'Stream #0.*?Audio: (\w+)', output) if codec_match: codec = codec_match.group(1) - br_match = re.search(r"(\d+) [kK]b/s", output) + br_match = re.search(r'(\d+) [kK]b/s', output) if br_match: bitrate = max(int(br_match.group(1)), 512) @@ -825,7 +826,7 @@ def _speak(self, speaking: SpeakingState) -> None: try: asyncio.run_coroutine_threadsafe(self.client.ws.speak(speaking), self.client.client.loop) except Exception: - _log.exception("Speaking call in player failed") + _log.exception('Speaking call in player failed') def send_silence(self, count: int = 5) -> None: try: diff --git a/discord/poll.py b/discord/poll.py index 6ab680abd26e..a191319d956c 100644 --- a/discord/poll.py +++ b/discord/poll.py @@ -483,7 +483,7 @@ def _to_dict(self) -> PollCreatePayload: return data def __repr__(self) -> str: - return f"" + return f'' @property def question(self) -> str: diff --git a/discord/presences.py b/discord/presences.py index 7fec2a09dfcc..d8a93f03ef0f 100644 --- a/discord/presences.py +++ b/discord/presences.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from typing import TYPE_CHECKING, Optional, Tuple diff --git a/discord/primary_guild.py b/discord/primary_guild.py index b65275a1f3b3..85e40159a40a 100644 --- a/discord/primary_guild.py +++ b/discord/primary_guild.py @@ -83,7 +83,7 @@ def created_at(self) -> Optional[datetime]: @classmethod def _default(cls, state: ConnectionState) -> Self: - payload: PrimaryGuildPayload = {"identity_enabled": False} # type: ignore + payload: PrimaryGuildPayload = {'identity_enabled': False} # type: ignore return cls(state=state, data=payload) def __repr__(self) -> str: diff --git a/discord/role.py b/discord/role.py index 18b02405946a..55996c3ae0f6 100644 --- a/discord/role.py +++ b/discord/role.py @@ -429,10 +429,10 @@ def flags(self) -> RoleFlags: async def _move(self, position: int, reason: Optional[str]) -> None: if position <= 0: - raise ValueError("Cannot move role to position 0 or below") + raise ValueError('Cannot move role to position 0 or below') if self.is_default(): - raise ValueError("Cannot move default role") + raise ValueError('Cannot move default role') if self.position == position: return # Save discord the extra request. @@ -447,7 +447,7 @@ async def _move(self, position: int, reason: Optional[str]) -> None: else: roles.append(self.id) - payload: List[RolePositionUpdate] = [{"id": z[0], "position": z[1]} for z in zip(roles, change_range)] + payload: List[RolePositionUpdate] = [{'id': z[0], 'position': z[1]} for z in zip(roles, change_range)] await http.move_role_position(self.guild.id, payload, reason=reason) async def edit( @@ -599,20 +599,16 @@ async def edit( return Role(guild=self.guild, data=data, state=self._state) @overload - async def move(self, *, beginning: bool, offset: int = ..., reason: Optional[str] = ...): - ... + async def move(self, *, beginning: bool, offset: int = ..., reason: Optional[str] = ...): ... @overload - async def move(self, *, end: bool, offset: int = ..., reason: Optional[str] = ...): - ... + async def move(self, *, end: bool, offset: int = ..., reason: Optional[str] = ...): ... @overload - async def move(self, *, above: Role, offset: int = ..., reason: Optional[str] = ...): - ... + async def move(self, *, above: Role, offset: int = ..., reason: Optional[str] = ...): ... @overload - async def move(self, *, below: Role, offset: int = ..., reason: Optional[str] = ...): - ... + async def move(self, *, below: Role, offset: int = ..., reason: Optional[str] = ...): ... async def move( self, diff --git a/discord/scheduled_event.py b/discord/scheduled_event.py index f74ae67061e5..7372487fcf13 100644 --- a/discord/scheduled_event.py +++ b/discord/scheduled_event.py @@ -310,8 +310,7 @@ async def edit( status: EventStatus = ..., image: bytes = ..., reason: Optional[str] = ..., - ) -> ScheduledEvent: - ... + ) -> ScheduledEvent: ... @overload async def edit( @@ -327,8 +326,7 @@ async def edit( status: EventStatus = ..., image: bytes = ..., reason: Optional[str] = ..., - ) -> ScheduledEvent: - ... + ) -> ScheduledEvent: ... @overload async def edit( @@ -344,8 +342,7 @@ async def edit( image: bytes = ..., location: str, reason: Optional[str] = ..., - ) -> ScheduledEvent: - ... + ) -> ScheduledEvent: ... @overload async def edit( @@ -360,8 +357,7 @@ async def edit( status: EventStatus = ..., image: bytes = ..., reason: Optional[str] = ..., - ) -> ScheduledEvent: - ... + ) -> ScheduledEvent: ... @overload async def edit( @@ -376,8 +372,7 @@ async def edit( image: bytes = ..., location: str, reason: Optional[str] = ..., - ) -> ScheduledEvent: - ... + ) -> ScheduledEvent: ... async def edit( self, diff --git a/discord/shard.py b/discord/shard.py index 35e46c7fa34a..7198887cfe04 100644 --- a/discord/shard.py +++ b/discord/shard.py @@ -319,7 +319,7 @@ class SessionStartLimits: The number of identify requests allowed per 5 seconds """ - __slots__ = ("total", "remaining", "reset_after", "max_concurrency") + __slots__ = ('total', 'remaining', 'reset_after', 'max_concurrency') def __init__(self, **kwargs: Unpack[SessionStartLimit]): self.total: int = kwargs['total'] diff --git a/discord/sku.py b/discord/sku.py index 3516370b4ee1..840759f5c084 100644 --- a/discord/sku.py +++ b/discord/sku.py @@ -22,7 +22,6 @@ DEALINGS IN THE SOFTWARE. """ - from __future__ import annotations from typing import AsyncIterator, Optional, TYPE_CHECKING diff --git a/discord/soundboard.py b/discord/soundboard.py index 3351aacb78ff..1773b9830b83 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -146,7 +146,7 @@ def __repr__(self) -> str: ('emoji', self.emoji), ] inner = ' '.join('%s=%r' % t for t in attrs) - return f"<{self.__class__.__name__} {inner}>" + return f'<{self.__class__.__name__} {inner}>' class SoundboardSound(BaseSoundboardSound): @@ -203,7 +203,7 @@ def __repr__(self) -> str: ('user', self.user), ] inner = ' '.join('%s=%r' % t for t in attrs) - return f"<{self.__class__.__name__} {inner}>" + return f'<{self.__class__.__name__} {inner}>' def _update(self, data: SoundboardSoundPayload): super()._update(data) diff --git a/discord/state.py b/discord/state.py index 90f482856a9e..74922907da33 100644 --- a/discord/state.py +++ b/discord/state.py @@ -1265,14 +1265,12 @@ def is_guild_evicted(self, guild: Guild) -> bool: return guild.id not in self._guilds @overload - async def chunk_guild(self, guild: Guild, *, wait: Literal[True] = ..., cache: Optional[bool] = ...) -> List[Member]: - ... + async def chunk_guild(self, guild: Guild, *, wait: Literal[True] = ..., cache: Optional[bool] = ...) -> List[Member]: ... @overload async def chunk_guild( self, guild: Guild, *, wait: Literal[False] = ..., cache: Optional[bool] = ... - ) -> asyncio.Future[List[Member]]: - ... + ) -> asyncio.Future[List[Member]]: ... async def chunk_guild( self, guild: Guild, *, wait: bool = True, cache: Optional[bool] = None @@ -1803,7 +1801,10 @@ def get_emoji_from_partial_payload(self, data: PartialEmojiPayload) -> Union[Emo return self._emojis[emoji_id] except KeyError: return PartialEmoji.with_state( - self, animated=data.get('animated', False), id=emoji_id, name=data['name'] # type: ignore + self, + animated=data.get('animated', False), + id=emoji_id, + name=data['name'], # type: ignore ) def _upgrade_partial_emoji(self, emoji: PartialEmoji) -> Union[Emoji, PartialEmoji, str]: diff --git a/discord/types/automod.py b/discord/types/automod.py index 246b7ee6a73b..764fb1800fe0 100644 --- a/discord/types/automod.py +++ b/discord/types/automod.py @@ -33,8 +33,7 @@ AutoModerationTriggerPresets = Literal[1, 2, 3] -class Empty(TypedDict): - ... +class Empty(TypedDict): ... class _AutoModerationActionMetadataAlert(TypedDict): diff --git a/discord/types/command.py b/discord/types/command.py index 7876ee6ddf0e..0fbe7fbb51af 100644 --- a/discord/types/command.py +++ b/discord/types/command.py @@ -163,7 +163,7 @@ class _ChatInputApplicationCommand(_BaseApplicationCommand, total=False): class _BaseContextMenuApplicationCommand(_BaseApplicationCommand): - description: Literal[""] + description: Literal[''] class _UserApplicationCommand(_BaseContextMenuApplicationCommand): diff --git a/discord/types/guild.py b/discord/types/guild.py index d491eda47b5c..9146e8ba97af 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -116,8 +116,7 @@ class _GuildPreviewUnique(TypedDict): approximate_presence_count: int -class GuildPreview(_BaseGuildPreview, _GuildPreviewUnique): - ... +class GuildPreview(_BaseGuildPreview, _GuildPreviewUnique): ... class Guild(_BaseGuildPreview): @@ -167,8 +166,7 @@ class InviteGuild(Guild, total=False): welcome_screen: WelcomeScreen -class GuildWithCounts(Guild, _GuildPreviewUnique): - ... +class GuildWithCounts(Guild, _GuildPreviewUnique): ... class GuildPrune(TypedDict): diff --git a/discord/types/invite.py b/discord/types/invite.py index 06bd6649b89a..38e28f959d4b 100644 --- a/discord/types/invite.py +++ b/discord/types/invite.py @@ -68,8 +68,7 @@ class Invite(IncompleteInvite, total=False): expires_at: Optional[str] -class InviteWithCounts(Invite, _GuildPreviewUnique): - ... +class InviteWithCounts(Invite, _GuildPreviewUnique): ... class GatewayInviteCreate(TypedDict): diff --git a/discord/types/scheduled_event.py b/discord/types/scheduled_event.py index 52200367f134..1f558626c0b7 100644 --- a/discord/types/scheduled_event.py +++ b/discord/types/scheduled_event.py @@ -81,16 +81,13 @@ class _WithUserCount(TypedDict): user_count: int -class _StageInstanceScheduledEventWithUserCount(StageInstanceScheduledEvent, _WithUserCount): - ... +class _StageInstanceScheduledEventWithUserCount(StageInstanceScheduledEvent, _WithUserCount): ... -class _VoiceScheduledEventWithUserCount(VoiceScheduledEvent, _WithUserCount): - ... +class _VoiceScheduledEventWithUserCount(VoiceScheduledEvent, _WithUserCount): ... -class _ExternalScheduledEventWithUserCount(ExternalScheduledEvent, _WithUserCount): - ... +class _ExternalScheduledEventWithUserCount(ExternalScheduledEvent, _WithUserCount): ... GuildScheduledEventWithUserCount = Union[ diff --git a/discord/types/webhook.py b/discord/types/webhook.py index dd5eea15630c..a954689dc51b 100644 --- a/discord/types/webhook.py +++ b/discord/types/webhook.py @@ -63,5 +63,4 @@ class _FullWebhook(TypedDict, total=False): application_id: Optional[Snowflake] -class Webhook(PartialWebhook, _FullWebhook): - ... +class Webhook(PartialWebhook, _FullWebhook): ... diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index f4ffc3e2cca2..1cdf4b6f1a17 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from typing import ( @@ -405,8 +406,7 @@ def select( max_values: int = ..., disabled: bool = ..., id: Optional[int] = ..., - ) -> SelectCallbackDecorator[S, SelectT]: - ... + ) -> SelectCallbackDecorator[S, SelectT]: ... @overload def select( @@ -422,8 +422,7 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., id: Optional[int] = ..., - ) -> SelectCallbackDecorator[S, UserSelectT]: - ... + ) -> SelectCallbackDecorator[S, UserSelectT]: ... @overload def select( @@ -439,8 +438,7 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., id: Optional[int] = ..., - ) -> SelectCallbackDecorator[S, RoleSelectT]: - ... + ) -> SelectCallbackDecorator[S, RoleSelectT]: ... @overload def select( @@ -456,8 +454,7 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., id: Optional[int] = ..., - ) -> SelectCallbackDecorator[S, ChannelSelectT]: - ... + ) -> SelectCallbackDecorator[S, ChannelSelectT]: ... @overload def select( @@ -473,8 +470,7 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., id: Optional[int] = ..., - ) -> SelectCallbackDecorator[S, MentionableSelectT]: - ... + ) -> SelectCallbackDecorator[S, MentionableSelectT]: ... def select( self, diff --git a/discord/ui/media_gallery.py b/discord/ui/media_gallery.py index ddcf581fadc5..0a6aea151f04 100644 --- a/discord/ui/media_gallery.py +++ b/discord/ui/media_gallery.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from typing import TYPE_CHECKING, List, Literal, Optional, TypeVar, Union diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 39f6bd9062c7..3900b49c22d8 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -176,7 +176,7 @@ def _refresh(self, interaction: Interaction, components: Sequence[ModalSubmitCom else: item = find(lambda i: getattr(i, 'custom_id', None) == component['custom_id'], self.walk_children()) # type: ignore if item is None: - _log.debug("Modal interaction referencing unknown item custom_id %s. Discarding", component['custom_id']) + _log.debug('Modal interaction referencing unknown item custom_id %s. Discarding', component['custom_id']) continue item._refresh_state(interaction, component) # type: ignore diff --git a/discord/ui/section.py b/discord/ui/section.py index c39c54789723..bd31ebcdbf5a 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from typing import TYPE_CHECKING, Any, Dict, Generator, List, Literal, Optional, TypeVar, Union, ClassVar diff --git a/discord/ui/select.py b/discord/ui/select.py index b2db0e10eb02..a181357b73bf 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -361,11 +361,12 @@ def _refresh_state(self, interaction: Interaction, data: SelectMessageComponentI payload: List[PossibleValue] try: resolved = Namespace._get_resolved_items( - interaction, data['resolved'] # pyright: ignore[reportTypedDictNotRequiredAccess] + interaction, + data['resolved'], # pyright: ignore[reportTypedDictNotRequiredAccess] ) payload = list(resolved.values()) except KeyError: - payload = data.get("values", []) # type: ignore + payload = data.get('values', []) # type: ignore self._values = values[self.custom_id] = payload selected_values.set(values) @@ -985,8 +986,7 @@ def select( disabled: bool = ..., row: Optional[int] = ..., id: Optional[int] = ..., -) -> SelectCallbackDecorator[S, SelectT]: - ... +) -> SelectCallbackDecorator[S, SelectT]: ... @overload @@ -1003,8 +1003,7 @@ def select( default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., id: Optional[int] = ..., -) -> SelectCallbackDecorator[S, UserSelectT]: - ... +) -> SelectCallbackDecorator[S, UserSelectT]: ... @overload @@ -1021,8 +1020,7 @@ def select( default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., id: Optional[int] = ..., -) -> SelectCallbackDecorator[S, RoleSelectT]: - ... +) -> SelectCallbackDecorator[S, RoleSelectT]: ... @overload @@ -1039,8 +1037,7 @@ def select( default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., id: Optional[int] = ..., -) -> SelectCallbackDecorator[S, ChannelSelectT]: - ... +) -> SelectCallbackDecorator[S, ChannelSelectT]: ... @overload @@ -1057,8 +1054,7 @@ def select( default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., id: Optional[int] = ..., -) -> SelectCallbackDecorator[S, MentionableSelectT]: - ... +) -> SelectCallbackDecorator[S, MentionableSelectT]: ... def select( diff --git a/discord/ui/separator.py b/discord/ui/separator.py index e9ba7d789c5d..e6dc61f0002f 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations from typing import TYPE_CHECKING, Literal, Optional, TypeVar diff --git a/discord/ui/view.py b/discord/ui/view.py index d23b9fd0ac6f..01f8543c6a3b 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -689,8 +689,7 @@ class View(BaseView): if TYPE_CHECKING: @classmethod - def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View: - ... + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View: ... def __init_subclass__(cls) -> None: super().__init_subclass__() @@ -783,8 +782,7 @@ class LayoutView(BaseView): if TYPE_CHECKING: @classmethod - def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> LayoutView: - ... + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> LayoutView: ... def __init__(self, *, timeout: Optional[float] = 180.0) -> None: super().__init__(timeout=timeout) @@ -1046,7 +1044,7 @@ def dispatch_modal( ) -> None: modal = self._modals.get(custom_id) if modal is None: - _log.debug("Modal interaction referencing unknown custom_id %s. Discarding", custom_id) + _log.debug('Modal interaction referencing unknown custom_id %s. Discarding', custom_id) return self.add_task(modal._dispatch_submit(interaction, components)) diff --git a/discord/user.py b/discord/user.py index 636c909f3b40..751437532c3a 100644 --- a/discord/user.py +++ b/discord/user.py @@ -101,8 +101,8 @@ def __init__(self, *, state: ConnectionState, data: Union[UserPayload, PartialUs def __repr__(self) -> str: return ( - f"" + f'' ) def __str__(self) -> str: diff --git a/discord/utils.py b/discord/utils.py index bcdf922b402b..dc90c8b956ca 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -158,8 +158,7 @@ def __get__(self, instance, owner): class _DecompressionContext(Protocol): COMPRESSION_TYPE: str - def decompress(self, data: bytes, /) -> str | None: - ... + def decompress(self, data: bytes, /) -> str | None: ... P = ParamSpec('P') @@ -186,12 +185,10 @@ def __init__(self, name: str, function: Callable[[T], T_co]) -> None: self.__doc__ = getattr(function, '__doc__') @overload - def __get__(self, instance: None, owner: Type[T]) -> CachedSlotProperty[T, T_co]: - ... + def __get__(self, instance: None, owner: Type[T]) -> CachedSlotProperty[T, T_co]: ... @overload - def __get__(self, instance: T, owner: Type[T]) -> T_co: - ... + def __get__(self, instance: T, owner: Type[T]) -> T_co: ... def __get__(self, instance: Optional[T], owner: Type[T]) -> Any: if instance is None: @@ -240,15 +237,13 @@ def __copied(self) -> List[T_co]: return self.__proxied def __repr__(self) -> str: - return f"SequenceProxy({self.__proxied!r})" + return f'SequenceProxy({self.__proxied!r})' @overload - def __getitem__(self, idx: SupportsIndex) -> T_co: - ... + def __getitem__(self, idx: SupportsIndex) -> T_co: ... @overload - def __getitem__(self, idx: slice) -> List[T_co]: - ... + def __getitem__(self, idx: slice) -> List[T_co]: ... def __getitem__(self, idx: Union[SupportsIndex, slice]) -> Union[T_co, List[T_co]]: return self.__copied[idx] @@ -273,18 +268,15 @@ def count(self, value: Any) -> int: @overload -def parse_time(timestamp: None) -> None: - ... +def parse_time(timestamp: None) -> None: ... @overload -def parse_time(timestamp: str) -> datetime.datetime: - ... +def parse_time(timestamp: str) -> datetime.datetime: ... @overload -def parse_time(timestamp: Optional[str]) -> Optional[datetime.datetime]: - ... +def parse_time(timestamp: Optional[str]) -> Optional[datetime.datetime]: ... def parse_time(timestamp: Optional[str]) -> Optional[datetime.datetime]: @@ -308,7 +300,7 @@ def actual_decorator(func: Callable[P, T]) -> Callable[P, T]: def decorated(*args: P.args, **kwargs: P.kwargs) -> T: warnings.simplefilter('always', DeprecationWarning) # turn off filter if instead: - fmt = "{0.__name__} is deprecated, use {1} instead." + fmt = '{0.__name__} is deprecated, use {1} instead.' else: fmt = '{0.__name__} is deprecated.' @@ -447,13 +439,11 @@ async def _afind(predicate: Callable[[T], Any], iterable: AsyncIterable[T], /) - @overload -def find(predicate: Callable[[T], Any], iterable: AsyncIterable[T], /) -> Coro[Optional[T]]: - ... +def find(predicate: Callable[[T], Any], iterable: AsyncIterable[T], /) -> Coro[Optional[T]]: ... @overload -def find(predicate: Callable[[T], Any], iterable: Iterable[T], /) -> Optional[T]: - ... +def find(predicate: Callable[[T], Any], iterable: Iterable[T], /) -> Optional[T]: ... def find(predicate: Callable[[T], Any], iterable: _Iter[T], /) -> Union[Optional[T], Coro[Optional[T]]]: @@ -533,13 +523,11 @@ async def _aget(iterable: AsyncIterable[T], /, **attrs: Any) -> Optional[T]: @overload -def get(iterable: AsyncIterable[T], /, **attrs: Any) -> Coro[Optional[T]]: - ... +def get(iterable: AsyncIterable[T], /, **attrs: Any) -> Coro[Optional[T]]: ... @overload -def get(iterable: Iterable[T], /, **attrs: Any) -> Optional[T]: - ... +def get(iterable: Iterable[T], /, **attrs: Any) -> Optional[T]: ... def get(iterable: _Iter[T], /, **attrs: Any) -> Union[Optional[T], Coro[Optional[T]]]: @@ -622,7 +610,7 @@ def _get_as_snowflake(data: Any, key: str) -> Optional[int]: def _get_mime_type_for_image(data: bytes): - if data.startswith(b'\x89\x50\x4E\x47\x0D\x0A\x1A\x0A'): + if data.startswith(b'\x89\x50\x4e\x47\x0d\x0a\x1a\x0a'): return 'image/png' elif data[0:3] == b'\xff\xd8\xff' or data[6:10] in (b'JFIF', b'Exif'): return 'image/jpeg' @@ -756,13 +744,11 @@ def compute_timedelta(dt: datetime.datetime) -> float: @overload -async def sleep_until(when: datetime.datetime, result: T) -> T: - ... +async def sleep_until(when: datetime.datetime, result: T) -> T: ... @overload -async def sleep_until(when: datetime.datetime) -> None: - ... +async def sleep_until(when: datetime.datetime) -> None: ... async def sleep_until(when: datetime.datetime, result: Optional[T] = None) -> Optional[T]: @@ -823,8 +809,7 @@ class SnowflakeList(_SnowflakeListBase): if TYPE_CHECKING: - def __init__(self, data: Iterable[int], *, is_sorted: bool = False): - ... + def __init__(self, data: Iterable[int], *, is_sorted: bool = False): ... def __new__(cls, data: Iterable[int], *, is_sorted: bool = False) -> Self: return array.array.__new__(cls, 'Q', data if is_sorted else sorted(data)) # type: ignore @@ -934,11 +919,11 @@ def resolve_template(code: Union[Template, str]) -> str: _MARKDOWN_ESCAPE_COMMON = r'^>(?:>>)?\s|\[.+\]\(.+\)|^#{1,3}|^\s*-' -_MARKDOWN_ESCAPE_REGEX = re.compile(fr'(?P{_MARKDOWN_ESCAPE_SUBREGEX}|{_MARKDOWN_ESCAPE_COMMON})', re.MULTILINE) +_MARKDOWN_ESCAPE_REGEX = re.compile(rf'(?P{_MARKDOWN_ESCAPE_SUBREGEX}|{_MARKDOWN_ESCAPE_COMMON})', re.MULTILINE) _URL_REGEX = r'(?P<[^: >]+:\/[^ >]+>|(?:https?|steam):\/\/[^\s<]+[^<.,:;\"\'\]\s])' -_MARKDOWN_STOCK_REGEX = fr'(?P[_\\~|\*`]|{_MARKDOWN_ESCAPE_COMMON})' +_MARKDOWN_STOCK_REGEX = rf'(?P[_\\~|\*`]|{_MARKDOWN_ESCAPE_COMMON})' def remove_markdown(text: str, *, ignore_links: bool = True) -> str: @@ -1073,13 +1058,11 @@ async def _achunk(iterator: AsyncIterable[T], max_size: int) -> AsyncIterator[Li @overload -def as_chunks(iterator: AsyncIterable[T], max_size: int) -> AsyncIterator[List[T]]: - ... +def as_chunks(iterator: AsyncIterable[T], max_size: int) -> AsyncIterator[List[T]]: ... @overload -def as_chunks(iterator: Iterable[T], max_size: int) -> Iterator[List[T]]: - ... +def as_chunks(iterator: Iterable[T], max_size: int) -> Iterator[List[T]]: ... def as_chunks(iterator: _Iter[T], max_size: int) -> _Iter[List[T]]: @@ -1304,7 +1287,6 @@ def stream_supports_colour(stream: Any) -> bool: class _ColourFormatter(logging.Formatter): - # ANSI codes are a bit weird to decipher if you're unfamiliar with them, so here's a refresher # It starts off with a format like \x1b[XXXm where XXX is a semicolon separated list of commands # The important ones here relate to colour. @@ -1499,37 +1481,37 @@ def _format_call_duration(duration: datetime.timedelta) -> str: threshold_M = 10.5 if seconds < threshold_s: - formatted = "a few seconds" + formatted = 'a few seconds' elif seconds < (threshold_m * minutes_s): minutes = round(seconds / minutes_s) if minutes == 1: - formatted = "a minute" + formatted = 'a minute' else: - formatted = f"{minutes} minutes" + formatted = f'{minutes} minutes' elif seconds < (threshold_h * hours_s): hours = round(seconds / hours_s) if hours == 1: - formatted = "an hour" + formatted = 'an hour' else: - formatted = f"{hours} hours" + formatted = f'{hours} hours' elif seconds < (threshold_d * days_s): days = round(seconds / days_s) if days == 1: - formatted = "a day" + formatted = 'a day' else: - formatted = f"{days} days" + formatted = f'{days} days' elif seconds < (threshold_M * months_s): months = round(seconds / months_s) if months == 1: - formatted = "a month" + formatted = 'a month' else: - formatted = f"{months} months" + formatted = f'{months} months' else: years = round(seconds / years_s) if years == 1: - formatted = "a year" + formatted = 'a year' else: - formatted = f"{years} years" + formatted = f'{years} years' return formatted diff --git a/discord/voice_client.py b/discord/voice_client.py index 795434e1e722..b0f3e951bcce 100644 --- a/discord/voice_client.py +++ b/discord/voice_client.py @@ -217,7 +217,7 @@ class VoiceClient(VoiceProtocol): def __init__(self, client: Client, channel: abc.Connectable) -> None: if not has_nacl: - raise RuntimeError("PyNaCl library needed in order to use voice") + raise RuntimeError('PyNaCl library needed in order to use voice') super().__init__(client, channel) state = client._connection @@ -321,7 +321,7 @@ def latency(self) -> float: .. versionadded:: 1.4 """ ws = self._connection.ws - return float("inf") if not ws else ws.latency + return float('inf') if not ws else ws.latency @property def average_latency(self) -> float: @@ -330,7 +330,7 @@ def average_latency(self) -> float: .. versionadded:: 1.4 """ ws = self._connection.ws - return float("inf") if not ws else ws.average_latency + return float('inf') if not ws else ws.average_latency async def disconnect(self, *, force: bool = False) -> None: """|coro| diff --git a/discord/voice_state.py b/discord/voice_state.py index d2cc0ebc183b..5e78c7851954 100644 --- a/discord/voice_state.py +++ b/discord/voice_state.py @@ -148,7 +148,7 @@ def _do_run(self) -> None: readable, _, _ = select.select([self.state.socket], [], [], 30) except (ValueError, TypeError, OSError) as e: _log.debug( - "Select error handling socket in reader, this should be safe to ignore: %s: %s", e.__class__.__name__, e + 'Select error handling socket in reader, this should be safe to ignore: %s: %s', e.__class__.__name__, e ) # The socket is either closed or doesn't exist at the moment continue diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 30d0be78850c..9d4fa0da6f29 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -1324,7 +1324,7 @@ def from_url( @classmethod def _as_follower(cls, data, *, channel, user) -> Self: - name = f"{channel.guild} #{channel}" + name = f'{channel.guild} #{channel}' feed: WebhookPayload = { 'id': data['webhook_id'], 'type': 2, @@ -1622,8 +1622,7 @@ async def send( suppress_embeds: bool = MISSING, silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, - ) -> WebhookMessage: - ... + ) -> WebhookMessage: ... @overload async def send( @@ -1642,8 +1641,7 @@ async def send( suppress_embeds: bool = MISSING, silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, - ) -> None: - ... + ) -> None: ... @overload async def send( @@ -1667,8 +1665,7 @@ async def send( silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, - ) -> WebhookMessage: - ... + ) -> WebhookMessage: ... @overload async def send( @@ -1692,8 +1689,7 @@ async def send( silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, - ) -> None: - ... + ) -> None: ... async def send( self, @@ -1987,8 +1983,7 @@ async def edit_message( view: LayoutView, allowed_mentions: Optional[AllowedMentions] = ..., thread: Snowflake = ..., - ) -> WebhookMessage: - ... + ) -> WebhookMessage: ... @overload async def edit_message( @@ -2002,8 +1997,7 @@ async def edit_message( view: Optional[View] = ..., allowed_mentions: Optional[AllowedMentions] = ..., thread: Snowflake = ..., - ) -> WebhookMessage: - ... + ) -> WebhookMessage: ... async def edit_message( self, diff --git a/discord/webhook/sync.py b/discord/webhook/sync.py index 3891a9a39e73..1786496faaef 100644 --- a/discord/webhook/sync.py +++ b/discord/webhook/sync.py @@ -872,8 +872,7 @@ def send( suppress_embeds: bool = MISSING, silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, - ) -> SyncWebhookMessage: - ... + ) -> SyncWebhookMessage: ... @overload def send( @@ -891,8 +890,7 @@ def send( suppress_embeds: bool = MISSING, silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, - ) -> None: - ... + ) -> None: ... @overload def send( @@ -915,8 +913,7 @@ def send( applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, view: View = MISSING, - ) -> SyncWebhookMessage: - ... + ) -> SyncWebhookMessage: ... @overload def send( @@ -939,8 +936,7 @@ def send( applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, view: View = MISSING, - ) -> None: - ... + ) -> None: ... def send( self, @@ -1192,8 +1188,7 @@ def edit_message( view: LayoutView, allowed_mentions: Optional[AllowedMentions] = ..., thread: Snowflake = ..., - ) -> SyncWebhookMessage: - ... + ) -> SyncWebhookMessage: ... @overload def edit_message( @@ -1207,8 +1202,7 @@ def edit_message( view: Optional[View] = ..., allowed_mentions: Optional[AllowedMentions] = ..., thread: Snowflake = ..., - ) -> SyncWebhookMessage: - ... + ) -> SyncWebhookMessage: ... def edit_message( self, diff --git a/discord/widget.py b/discord/widget.py index cdb883fd96db..539f22f8d780 100644 --- a/discord/widget.py +++ b/discord/widget.py @@ -195,7 +195,7 @@ def __init__( self.connected_channel: Optional[WidgetChannel] = connected_channel def __repr__(self) -> str: - return f"" + return f'' @property def display_name(self) -> str: @@ -292,7 +292,7 @@ def created_at(self) -> datetime.datetime: @property def json_url(self) -> str: """:class:`str`: The JSON URL of the widget.""" - return f"https://discord.com/api/guilds/{self.id}/widget.json" + return f'https://discord.com/api/guilds/{self.id}/widget.json' @property def invite_url(self) -> Optional[str]: diff --git a/examples/advanced_startup.py b/examples/advanced_startup.py index 82d0a96d3820..c06edb53da43 100644 --- a/examples/advanced_startup.py +++ b/examples/advanced_startup.py @@ -31,7 +31,6 @@ def __init__( self.initial_extensions = initial_extensions async def setup_hook(self) -> None: - # here, we are loading extensions prior to sync to ensure we are syncing interactions defined in those extensions. for extension in self.initial_extensions: @@ -53,7 +52,6 @@ async def setup_hook(self) -> None: async def main(): - # When taking over how the bot process is run, you become responsible for a few additional things. # 1. logging diff --git a/examples/app_commands/basic.py b/examples/app_commands/basic.py index 7dc46c657130..fce7c4654340 100644 --- a/examples/app_commands/basic.py +++ b/examples/app_commands/basic.py @@ -92,6 +92,7 @@ async def joined(interaction: discord.Interaction, member: Optional[discord.Memb # accessing a menu within the client, usually via right clicking. # It always takes an interaction as its first parameter and a Member or Message as its second parameter. + # This context menu command only works on members @client.tree.context_menu(name='Show Join Date') async def show_join_date(interaction: discord.Interaction, member: discord.Member): diff --git a/examples/app_commands/transformers.py b/examples/app_commands/transformers.py index a8275ad07e2d..b6463327ba9d 100644 --- a/examples/app_commands/transformers.py +++ b/examples/app_commands/transformers.py @@ -60,6 +60,7 @@ async def add( # Examples of these include int, str, float, bool, User, Member, Role, and any channel type. # Since there are a lot of these, for brevity only a channel example will be included. + # This command shows how to only show text and voice channels to a user using the Union type hint # combined with the VoiceChannel and TextChannel types. @client.tree.command(name='channel-info') @@ -83,6 +84,7 @@ async def channel_info(interaction: discord.Interaction, channel: Union[discord. # In order to support choices, the library has a few ways of doing this. # The first one is using a typing.Literal for basic choices. + # On Discord, this will show up as two choices, Buy and Sell. # In the code, you will receive either 'Buy' or 'Sell' as a string. @client.tree.command() diff --git a/examples/basic_bot.py b/examples/basic_bot.py index 738ae291a4a5..94324fe03601 100644 --- a/examples/basic_bot.py +++ b/examples/basic_bot.py @@ -4,10 +4,10 @@ from discord.ext import commands import random -description = '''An example bot to showcase the discord.ext.commands extension +description = """An example bot to showcase the discord.ext.commands extension module. -There are a number of utility commands being showcased here.''' +There are a number of utility commands being showcased here.""" intents = discord.Intents.default() intents.members = True diff --git a/examples/basic_voice.py b/examples/basic_voice.py index e21dd684b6c1..37a13a257946 100644 --- a/examples/basic_voice.py +++ b/examples/basic_voice.py @@ -97,10 +97,10 @@ async def volume(self, ctx, volume: int): """Changes the player's volume""" if ctx.voice_client is None: - return await ctx.send("Not connected to a voice channel.") + return await ctx.send('Not connected to a voice channel.') ctx.voice_client.source.volume = volume / 100 - await ctx.send(f"Changed volume to {volume}%") + await ctx.send(f'Changed volume to {volume}%') @commands.command() async def stop(self, ctx): @@ -116,8 +116,8 @@ async def ensure_voice(self, ctx): if ctx.author.voice: await ctx.author.voice.channel.connect() else: - await ctx.send("You are not connected to a voice channel.") - raise commands.CommandError("Author not connected to a voice channel.") + await ctx.send('You are not connected to a voice channel.') + raise commands.CommandError('Author not connected to a voice channel.') elif ctx.voice_client.is_playing(): ctx.voice_client.stop() @@ -126,7 +126,7 @@ async def ensure_voice(self, ctx): intents.message_content = True bot = commands.Bot( - command_prefix=commands.when_mentioned_or("!"), + command_prefix=commands.when_mentioned_or('!'), description='Relatively simple music bot example', intents=intents, ) diff --git a/examples/converters.py b/examples/converters.py index c1809692e593..014f25b5a7bf 100644 --- a/examples/converters.py +++ b/examples/converters.py @@ -40,7 +40,7 @@ async def userinfo_error(ctx: commands.Context, error: commands.CommandError): # If the conversion above fails for any reason, it will raise `commands.BadArgument` # so we handle this in this error handler: if isinstance(error, commands.BadArgument): - return await ctx.send('Couldn\'t find that user.') + return await ctx.send("Couldn't find that user.") # The default `on_command_error` will ignore errors from this command # because we made our own command-specific error handler, # so we need to log tracebacks ourselves. diff --git a/examples/custom_context.py b/examples/custom_context.py index f7f74b1c3145..57dd8cd2a9a0 100644 --- a/examples/custom_context.py +++ b/examples/custom_context.py @@ -55,5 +55,5 @@ async def guess(ctx, number: int): # let people do very malicious things with your # bot. Try to use a file or something to keep # them private, and don't commit it to GitHub -token = "your token here" +token = 'your token here' bot.run(token) diff --git a/examples/modals/basic.py b/examples/modals/basic.py index 27215b669752..bbc17cb49471 100644 --- a/examples/modals/basic.py +++ b/examples/modals/basic.py @@ -70,7 +70,7 @@ async def on_error(self, interaction: discord.Interaction, error: Exception) -> client = MyClient() -@client.tree.command(guild=TEST_GUILD, description="Submit feedback") +@client.tree.command(guild=TEST_GUILD, description='Submit feedback') async def feedback(interaction: discord.Interaction): # Send the modal with an instance of our `Feedback` class # Since modals require an interaction, they cannot be done as a response to a text command. diff --git a/examples/modals/label.py b/examples/modals/label.py index 697744b80a4a..1f3087f52de5 100644 --- a/examples/modals/label.py +++ b/examples/modals/label.py @@ -84,7 +84,7 @@ async def on_error(self, interaction: discord.Interaction, error: Exception) -> client = MyClient() -@client.tree.command(guild=TEST_GUILD, description="Timeout a member") +@client.tree.command(guild=TEST_GUILD, description='Timeout a member') async def timeout(interaction: discord.Interaction, member: discord.Member): # Send the modal with an instance of our `TimeoutModal` class # Since modals require an interaction, they cannot be done as a response to a text command. diff --git a/examples/secret.py b/examples/secret.py index 1d7e5587740b..7a649f4c0d07 100644 --- a/examples/secret.py +++ b/examples/secret.py @@ -5,7 +5,8 @@ intents = discord.Intents.default() -bot = commands.Bot(command_prefix=commands.when_mentioned, description="Nothing to see here!", intents=intents) +bot = commands.Bot(command_prefix=commands.when_mentioned, description='Nothing to see here!', intents=intents) + # the `hidden` keyword argument hides it from the help command. @bot.group(hidden=True) diff --git a/examples/views/counter.py b/examples/views/counter.py index 6d18c3be5633..98c1e269104a 100644 --- a/examples/views/counter.py +++ b/examples/views/counter.py @@ -22,7 +22,6 @@ async def on_ready(self): # Define a simple View that gives us a counter button class Counter(discord.ui.View): - # Define the actual button # When pressed, this increments the number displayed until it hits 5. # When it hits 5, the counter button is disabled and it turns green. diff --git a/examples/views/dropdown.py b/examples/views/dropdown.py index d02921b0a353..0c9dab2fe78f 100644 --- a/examples/views/dropdown.py +++ b/examples/views/dropdown.py @@ -3,12 +3,12 @@ import discord from discord.ext import commands + # Defines a custom Select containing colour options # that the user can choose. The callback function # of this class is called when the user changes their choice class Dropdown(discord.ui.Select): def __init__(self): - # Set the options that will be presented inside the dropdown options = [ discord.SelectOption(label='Red', description='Your favourite colour is red', emoji='🟥'), diff --git a/examples/views/dynamic_counter.py b/examples/views/dynamic_counter.py index 848b569a6190..531cb24b0892 100644 --- a/examples/views/dynamic_counter.py +++ b/examples/views/dynamic_counter.py @@ -15,6 +15,7 @@ # `counter:5:user:80088516616269824` where the first number is the current count and the # second number is the user ID who owns the button. + # Note that custom_ids can only be up to 100 characters long. class DynamicCounter( discord.ui.DynamicItem[discord.ui.Button], diff --git a/examples/views/ephemeral.py b/examples/views/ephemeral.py index 3864e945d4a0..b5966a8e1cd1 100644 --- a/examples/views/ephemeral.py +++ b/examples/views/ephemeral.py @@ -22,7 +22,6 @@ async def on_ready(self): # Define a simple View that gives us a counter button class Counter(discord.ui.View): - # Define the actual button # When pressed, this increments the number displayed until it hits 5. # When it hits 5, the counter button is disabled and it turns green. diff --git a/examples/views/settings.py b/examples/views/settings.py index 00b19715468b..acf8779b3247 100644 --- a/examples/views/settings.py +++ b/examples/views/settings.py @@ -9,24 +9,24 @@ class FruitType(enum.Enum): - apple = "Apple" - banana = "Banana" - orange = "Orange" - grape = "Grape" - mango = "Mango" - watermelon = "Watermelon" - coconut = "Coconut" + apple = 'Apple' + banana = 'Banana' + orange = 'Orange' + grape = 'Grape' + mango = 'Mango' + watermelon = 'Watermelon' + coconut = 'Coconut' @property def emoji(self) -> str: emojis = { - "Apple": "🍎", - "Banana": "🍌", - "Orange": "🍊", - "Grape": "🍇", - "Mango": "🥭", - "Watermelon": "🍉", - "Coconut": "🥥", + 'Apple': '🍎', + 'Banana': '🍌', + 'Orange': '🍊', + 'Grape': '🍇', + 'Mango': '🥭', + 'Watermelon': '🍉', + 'Coconut': '🥥', } return emojis[self.value] diff --git a/examples/views/tic_tac_toe.py b/examples/views/tic_tac_toe.py index f016c72ca546..3a70650ce470 100644 --- a/examples/views/tic_tac_toe.py +++ b/examples/views/tic_tac_toe.py @@ -4,6 +4,7 @@ from discord.ext import commands import discord + # Defines a custom button that contains the logic of the game. # The ['TicTacToe'] bit is for type hinting purposes to tell your IDE or linter # what the type of `self.view` is. It is not required. diff --git a/pyproject.toml b/pyproject.toml index 92ccb738106d..20d117b01fe3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ test = [ "tzdata; sys_platform == 'win32'", ] dev = [ - "black==22.6", + "ruff==0.12", "typing_extensions>=4.3,<5", ] @@ -90,6 +90,13 @@ include-package-data = true line-length = 125 skip-string-normalization = true +[tool.ruff] +line-length = 125 + +[tool.ruff.format] +line-ending = "lf" +quote-style = "single" + [tool.coverage.run] omit = [ "discord/__main__.py", From ad4881592622251451199240a1a8f3c8a7b64b6b Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 19 Aug 2025 03:58:40 -0400 Subject: [PATCH 091/138] Add Permissions.pin_messages --- discord/permissions.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/discord/permissions.py b/discord/permissions.py index 9d3cac89ff11..59cb685e5537 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -93,6 +93,7 @@ class _BasePermissionsKwargs(Generic[BoolOrNoneT], TypedDict, total=False): send_polls: BoolOrNoneT create_polls: BoolOrNoneT use_external_apps: BoolOrNoneT + pin_messages: BoolOrNoneT class _PermissionsKwargs(_BasePermissionsKwargs[bool]): ... @@ -251,7 +252,7 @@ def all(cls) -> Self: permissions set to ``True``. """ # Some of these are 0 because we don't want to set unnecessary bits - return cls(0b0000_0000_0000_0110_0111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111) + return cls(0b0000_0000_0000_1110_0111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111) @classmethod def _timeout_mask(cls) -> int: @@ -266,6 +267,7 @@ def _dm_permissions(cls) -> Self: base.read_messages = True base.send_tts_messages = False base.manage_messages = False + base.pin_messages = True base.create_private_threads = False base.create_public_threads = False base.manage_threads = False @@ -324,7 +326,7 @@ def all_channel(cls) -> Self: Added :attr:`send_polls`, :attr:`send_voice_messages`, attr:`use_external_sounds`, :attr:`use_embedded_activities`, and :attr:`use_external_apps` permissions. """ - return cls(0b0000_0000_0000_0110_0110_0100_1111_1101_1011_0011_1111_0111_1111_1111_0101_0001) + return cls(0b0000_0000_0000_1110_0110_0100_1111_1101_1011_0011_1111_0111_1111_1111_0101_0001) @classmethod def general(cls) -> Self: @@ -372,8 +374,11 @@ def text(cls) -> Self: .. versionchanged:: 2.4 Added :attr:`send_polls` and :attr:`use_external_apps` permissions. + + .. versionchanged:: 2.7 + Added :attr:`pin_messages` permission. """ - return cls(0b0000_0000_0000_0110_0100_0000_0111_1100_1000_0000_0000_0111_1111_1000_0100_0000) + return cls(0b0000_0000_0000_1110_0100_0000_0111_1100_1000_0000_0000_0111_1111_1000_0100_0000) @classmethod def voice(cls) -> Self: @@ -858,6 +863,14 @@ def use_external_apps(self) -> int: """ return 1 << 50 + @flag_value + def pin_messages(self) -> int: + """:class:`bool`: Returns ``True`` if a user can pin messages. + + .. versionadded:: 2.7 + """ + return 1 << 51 + def _augment_from_permissions(cls): cls.VALID_NAMES = set(Permissions.VALID_FLAGS) @@ -981,6 +994,7 @@ class PermissionOverwrite: send_polls: Optional[bool] create_polls: Optional[bool] use_external_apps: Optional[bool] + pin_messages: Optional[bool] def __init__(self, **kwargs: Unpack[_PermissionOverwriteKwargs]) -> None: self._values: Dict[str, Optional[bool]] = {} From a5a4bcf61bb06d0a937b4218ec16271ca074b199 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 19 Aug 2025 04:09:21 -0400 Subject: [PATCH 092/138] Update Permissions.manage_messages docstring --- discord/permissions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/permissions.py b/discord/permissions.py index 59cb685e5537..729763785e82 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -572,7 +572,7 @@ def send_tts_messages(self) -> int: @flag_value def manage_messages(self) -> int: - """:class:`bool`: Returns ``True`` if a user can delete or pin messages in a text channel. + """:class:`bool`: Returns ``True`` if a user can delete messages or bypass slowmode in a text channel. .. note:: From 9dbf13c2f962de631f99c042c59d924c1b7d19ea Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 19 Aug 2025 04:25:29 -0400 Subject: [PATCH 093/138] Document Section.accessory as an attribute --- discord/ui/section.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/discord/ui/section.py b/discord/ui/section.py index bd31ebcdbf5a..c6eaeabc7edd 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -57,6 +57,11 @@ class Section(Item[V]): The section accessory. id: Optional[:class:`int`] The ID of this component. This must be unique across the view. + + Attributes + ---------- + accessory: :class:`Item` + The section accessory. """ __item_repr_attributes__ = ( From 24aa98b923b0866ac8f03223397de311ddab95f1 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Wed, 20 Aug 2025 02:50:57 +0200 Subject: [PATCH 094/138] Fix Section.children and accessory's parent being None --- discord/ui/section.py | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/discord/ui/section.py b/discord/ui/section.py index c6eaeabc7edd..4d47dede6d42 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -57,11 +57,6 @@ class Section(Item[V]): The section accessory. id: Optional[:class:`int`] The ID of this component. This must be unique across the view. - - Attributes - ---------- - accessory: :class:`Item` - The section accessory. """ __item_repr_attributes__ = ( @@ -72,7 +67,7 @@ class Section(Item[V]): __slots__ = ( '_children', - 'accessory', + '_accessory', ) def __init__( @@ -83,13 +78,11 @@ def __init__( ) -> None: super().__init__() self._children: List[Item[V]] = [] - if children: - if len(children) > 3: - raise ValueError('maximum number of children exceeded') - self._children.extend( - [c if isinstance(c, Item) else TextDisplay(c) for c in children], - ) - self.accessory: Item[V] = accessory + for child in children: + self.add_item(child) + + accessory._parent = self + self._accessory: Item[V] = accessory self.id = id def __repr__(self) -> str: @@ -108,6 +101,19 @@ def children(self) -> List[Item[V]]: def width(self): return 5 + @property + def accessory(self) -> Item[V]: + """:class:`Item`: The section's accessory.""" + return self._accessory + + @accessory.setter + def accessory(self, value: Item[V]) -> None: + if not isinstance(value, Item): + raise TypeError(f'Expected an Item, got {value.__class__.__name__!r} instead') + + value._parent = self + self._accessory = value + def _is_v2(self) -> bool: return True From 283ed20956c53c1073a045b9951f4a9906108b3d Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 19 Aug 2025 20:55:02 -0400 Subject: [PATCH 095/138] Use List[Component] in docs instead of spelling them all out --- discord/message.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/message.py b/discord/message.py index db2e0e448329..02f31198d9e9 100644 --- a/discord/message.py +++ b/discord/message.py @@ -488,7 +488,7 @@ class MessageSnapshot: Extra features of the the message snapshot. stickers: List[:class:`StickerItem`] A list of sticker items given to the message. - components: List[Union[:class:`ActionRow`, :class:`Button`, :class:`SelectMenu`, :class:`Container`, :class:`SectionComponent`, :class:`TextDisplay`, :class:`MediaGalleryComponent`, :class:`FileComponent`, :class:`SeparatorComponent`, :class:`ThumbnailComponent`]] + components: List[:class:`Component`]] A list of components in the message. """ @@ -2099,7 +2099,7 @@ class Message(PartialMessage, Hashable): A list of sticker items given to the message. .. versionadded:: 1.6 - components: List[Union[:class:`ActionRow`, :class:`Button`, :class:`SelectMenu`]] + components: List[:class:`Component`] A list of components in the message. If :attr:`Intents.message_content` is not enabled this will always be an empty list unless the bot is mentioned or the message is a direct message. From 8f83267ce99c2c214d096dabb419165cda82e0f0 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 19 Aug 2025 21:09:09 -0400 Subject: [PATCH 096/138] Move UnfurledMediaItem and MediaGalleryItem to interactions page --- docs/api.rst | 17 ----------------- docs/interactions/api.rst | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 7a75ba25879b..53ddec06eecd 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -6091,23 +6091,6 @@ PollMedia .. autoclass:: PollMedia :members: -UnfurledMediaItem -~~~~~~~~~~~~~~~~~ - -.. attributetable:: UnfurledMediaItem - -.. autoclass:: UnfurledMediaItem - :members: - - -MediaGalleryItem -~~~~~~~~~~~~~~~~ - -.. attributetable:: MediaGalleryItem - -.. autoclass:: MediaGalleryItem - :members: - Exceptions ------------ diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index 1feeca879873..b2098128bd60 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -303,6 +303,24 @@ Choice :members: +UnfurledMediaItem +~~~~~~~~~~~~~~~~~ + +.. attributetable:: UnfurledMediaItem + +.. autoclass:: UnfurledMediaItem + :members: + + +MediaGalleryItem +~~~~~~~~~~~~~~~~ + +.. attributetable:: MediaGalleryItem + +.. autoclass:: MediaGalleryItem + :members: + + Enumerations ------------- From 27d69e98e2e9c5632bfd1d1b9ae6981d0b0fef72 Mon Sep 17 00:00:00 2001 From: Sacul Date: Wed, 20 Aug 2025 16:04:50 +0800 Subject: [PATCH 097/138] Fix note in button decorator in action row --- discord/ui/action_row.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 1cdf4b6f1a17..5b7dd4a27d22 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -348,6 +348,7 @@ def button( The function being decorated should have three parameters, ``self`` representing the :class:`discord.ui.ActionRow`, the :class:`discord.Interaction` you receive and the :class:`discord.ui.Button` being pressed. + .. note:: Buttons with a URL or a SKU cannot be created with this function. From 59e28af19f56efcd586d4945617708609f9e0a2b Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 20 Aug 2025 06:50:23 -0400 Subject: [PATCH 098/138] Fix Section.accessory being MISSING --- discord/ui/section.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/discord/ui/section.py b/discord/ui/section.py index 4d47dede6d42..5840185c227f 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -29,7 +29,7 @@ from .item import Item from .text_display import TextDisplay from ..enums import ComponentType -from ..utils import MISSING, get as _utils_get +from ..utils import get as _utils_get if TYPE_CHECKING: from typing_extensions import Self @@ -242,8 +242,8 @@ def from_component(cls, component: SectionComponent) -> Self: from .view import _component_to_item # using MISSING as accessory so we can create the new one with the parent set - self = cls(id=component.id, accessory=MISSING) - self.accessory = _component_to_item(component.accessory, self) + accessory = _component_to_item(component.accessory, None) + self = cls(id=component.id, accessory=accessory) self.id = component.id self._children = [_component_to_item(c, self) for c in component.children] From 20f4bc40ad5c9b6153062294032564d2f304b39b Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 20 Aug 2025 06:52:44 -0400 Subject: [PATCH 099/138] Remove outdated comment --- discord/ui/section.py | 1 - 1 file changed, 1 deletion(-) diff --git a/discord/ui/section.py b/discord/ui/section.py index 5840185c227f..a08f9e26b371 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -241,7 +241,6 @@ def clear_items(self) -> Self: def from_component(cls, component: SectionComponent) -> Self: from .view import _component_to_item - # using MISSING as accessory so we can create the new one with the parent set accessory = _component_to_item(component.accessory, None) self = cls(id=component.id, accessory=accessory) self.id = component.id From d897027c21f5415524c48217c53318eab0d369b0 Mon Sep 17 00:00:00 2001 From: Thanos <111999343+Sachaa-Thanasius@users.noreply.github.com> Date: Wed, 20 Aug 2025 18:34:17 -0400 Subject: [PATCH 100/138] Create .git-blame-ignore-revs --- .git-blame-ignore-revs | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 000000000000..16761a1db9c8 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Replace Black with Ruff, then format whole project. +44a44e938fb2bd0bb085d8aa4577abeb01653ad3 From b2aab86ba1b314f01aae53966559d5cceba37e37 Mon Sep 17 00:00:00 2001 From: Steve C Date: Wed, 20 Aug 2025 18:34:51 -0400 Subject: [PATCH 101/138] Add typing-extensions to lint workflow --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2cacf8f2b48a..73992a155241 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -27,7 +27,7 @@ jobs: - name: Install dependencies id: install-deps run: | - python -m pip install --upgrade pip setuptools wheel ruff==0.12 requests + python -m pip install --upgrade pip setuptools wheel ruff==0.12 requests "typing_extensions>=4.3,<5" pip install -U -r requirements.txt - name: Setup node.js From 0309aac3357a1f7250630aedc98e2b6b112528cc Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Thu, 21 Aug 2025 00:52:31 +0200 Subject: [PATCH 102/138] Add a swap method for dynamic item's dispatching --- discord/ui/action_row.py | 5 +++++ discord/ui/container.py | 5 +++++ discord/ui/item.py | 4 ++++ discord/ui/section.py | 5 +++++ discord/ui/view.py | 10 +++++++--- 5 files changed, 26 insertions(+), 3 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 5b7dd4a27d22..a8da7e67dfc2 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -67,6 +67,7 @@ from ..components import SelectOption from ..interactions import Interaction from .container import Container + from .dynamic import DynamicItem SelectCallbackDecorator = Callable[[ItemCallbackType['S', BaseSelectT]], BaseSelectT] @@ -194,6 +195,10 @@ def _is_v2(self) -> bool: # it should error anyways. return True + def _swap_item(self, base: Item, new: DynamicItem, custom_id: str) -> None: + child_index = self._children.index(base) + self._children[child_index] = new # type: ignore + @property def width(self): return 5 diff --git a/discord/ui/container.py b/discord/ui/container.py index 1dcdca6b22c3..67a58a6b47ff 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -50,6 +50,7 @@ from ..components import Container as ContainerComponent from ..interactions import Interaction + from .dynamic import DynamicItem S = TypeVar('S', bound='Container', covariant=True) V = TypeVar('V', bound='LayoutView', covariant=True) @@ -198,6 +199,10 @@ def _update_view(self, view) -> bool: def _has_children(self): return True + def _swap_item(self, base: Item, new: DynamicItem, custom_id: str) -> None: + child_index = self._children.index(base) + self._children[child_index] = new # type: ignore + @property def children(self) -> List[Item[V]]: """List[:class:`Item`]: The children of this container.""" diff --git a/discord/ui/item.py b/discord/ui/item.py index 9218d840d3fc..3bcb4ad96e85 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -44,6 +44,7 @@ from ..components import Component from .action_row import ActionRow from .container import Container + from .dynamic import DynamicItem I = TypeVar('I', bound='Item[Any]') V = TypeVar('V', bound='BaseView', covariant=True) @@ -118,6 +119,9 @@ def is_persistent(self) -> bool: return self._provided_custom_id return True + def _swap_item(self, base: Item, new: DynamicItem, custom_id: str) -> None: + raise ValueError + def __repr__(self) -> str: attrs = ' '.join(f'{key}={getattr(self, key)!r}' for key in self.__item_repr_attributes__) return f'<{self.__class__.__name__} {attrs}>' diff --git a/discord/ui/section.py b/discord/ui/section.py index a08f9e26b371..847ac1e19fef 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -35,6 +35,7 @@ from typing_extensions import Self from .view import LayoutView + from .dynamic import DynamicItem from ..components import SectionComponent V = TypeVar('V', bound='LayoutView', covariant=True) @@ -117,6 +118,10 @@ def accessory(self, value: Item[V]) -> None: def _is_v2(self) -> bool: return True + def _swap_item(self, base: Item, new: DynamicItem, custom_id: str) -> None: + if self.accessory.is_dispatchable() and getattr(self.accessory, 'custom_id', None) == custom_id: + self.accessory = new # type: ignore + def walk_children(self) -> Generator[Item[V], None, None]: """An iterator that recursively walks through all the children of this section and its children, if applicable. This includes the `accessory`. diff --git a/discord/ui/view.py b/discord/ui/view.py index 01f8543c6a3b..c66fdf18931e 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -300,6 +300,12 @@ def _refresh_timeout(self) -> None: if self.__timeout: self.__timeout_expiry = time.monotonic() + self.__timeout + def _swap_item(self, base: Item, new: DynamicItem, custom_id: str) -> None: + # if an error is raised it is catched by the try/except block that calls + # this function + child_index = self._children.index(base) + self._children[child_index] = new # type: ignore + @property def timeout(self) -> Optional[float]: """Optional[:class:`float`]: The timeout in seconds from last interaction with the UI before no longer accepting input. @@ -954,11 +960,9 @@ async def schedule_dynamic_item_call( parent = base_item._parent or view try: - child_index = parent._children.index(base_item) # type: ignore + parent._swap_item(base_item, item, custom_id) except ValueError: return - else: - parent._children[child_index] = item # type: ignore item._view = view item._rendered_row = base_item._rendered_row From 9bda89b0d6df93a68afd97314070b8d0413623bf Mon Sep 17 00:00:00 2001 From: Rapptz Date: Thu, 21 Aug 2025 22:20:12 -0400 Subject: [PATCH 103/138] Update total count tracking to always consider the wrapper object --- discord/ui/action_row.py | 5 +++++ discord/ui/container.py | 16 ++++++++-------- discord/ui/dynamic.py | 4 ++++ discord/ui/item.py | 4 ++++ discord/ui/section.py | 5 +++++ discord/ui/view.py | 12 ++---------- 6 files changed, 28 insertions(+), 18 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index a8da7e67dfc2..b0598db2015b 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -203,6 +203,11 @@ def _swap_item(self, base: Item, new: DynamicItem, custom_id: str) -> None: def width(self): return 5 + @property + def _total_count(self) -> int: + # 1 for self and all children + return 1 + len(self._children) + @property def type(self) -> Literal[ComponentType.action_row]: return ComponentType.action_row diff --git a/discord/ui/container.py b/discord/ui/container.py index 67a58a6b47ff..43f3ec1ee337 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -234,6 +234,11 @@ def type(self) -> Literal[ComponentType.container]: def width(self): return 5 + @property + def _total_count(self) -> int: + # 1 for self and all children + return 1 + len(tuple(self.walk_children())) + def _is_v2(self) -> bool: return True @@ -313,10 +318,8 @@ def add_item(self, item: Item[Any]) -> Self: if not isinstance(item, Item): raise TypeError(f'expected Item not {item.__class__.__name__}') - if item._has_children() and self._view: - self._view._add_count(len(tuple(item.walk_children()))) # type: ignore - elif self._view: - self._view._add_count(1) + if self._view: + self._view._add_count(item._total_count) self._children.append(item) item._update_view(self.view) @@ -341,10 +344,7 @@ def remove_item(self, item: Item[Any]) -> Self: pass else: if self._view: - if item._has_children(): - self._view._add_count(-len(tuple(item.walk_children()))) # type: ignore - else: - self._view._add_count(-1) + self._view._add_count(-item._total_count) return self def find_item(self, id: int, /) -> Optional[Item[V]]: diff --git a/discord/ui/dynamic.py b/discord/ui/dynamic.py index fb38b4b2e0cb..faed8c37002d 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -168,6 +168,10 @@ def row(self, value: Optional[int]) -> None: def width(self) -> int: return self.item.width + @property + def _total_count(self) -> int: + return self.item._total_count + @classmethod async def from_custom_id( cls: Type[Self], interaction: Interaction[ClientT], item: Item[Any], match: re.Match[str], / diff --git a/discord/ui/item.py b/discord/ui/item.py index 3bcb4ad96e85..5498dc20faca 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -147,6 +147,10 @@ def row(self, value: Optional[int]) -> None: def width(self) -> int: return 1 + @property + def _total_count(self) -> int: + return 1 + @property def view(self) -> Optional[V]: """Optional[Union[:class:`View`, :class:`LayoutView`]]: The underlying view for this item.""" diff --git a/discord/ui/section.py b/discord/ui/section.py index 847ac1e19fef..6a4026e22a88 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -102,6 +102,11 @@ def children(self) -> List[Item[V]]: def width(self): return 5 + @property + def _total_count(self) -> int: + # Count the accessory, ourselves, and all children + return 2 + len(self._children) + @property def accessory(self) -> Item[V]: """:class:`Item`: The section's accessory.""" diff --git a/discord/ui/view.py b/discord/ui/view.py index c66fdf18931e..cf6b495f0234 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -428,12 +428,7 @@ def add_item(self, item: Item[Any]) -> Self: raise ValueError('v2 items cannot be added to this view') item._update_view(self) - added = 1 - - if item._has_children(): - added += len(tuple(item.walk_children())) # type: ignore - - self._add_count(added) + self._add_count(item._total_count) self._children.append(item) return self @@ -454,10 +449,7 @@ def remove_item(self, item: Item[Any]) -> Self: except ValueError: pass else: - removed = 1 - if item._has_children(): - removed += len(tuple(item.walk_children())) # type: ignore - self._add_count(-removed) + self._add_count(-item._total_count) return self From 0b7d30bbe39162b69d585af183e9423f17d9fe72 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 22 Aug 2025 19:14:01 -0400 Subject: [PATCH 104/138] Fallback to None for AuditLogAction.category --- discord/enums.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/enums.py b/discord/enums.py index 4fe07ffce986..7dc4bccd05a4 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -488,7 +488,7 @@ def category(self) -> Optional[AuditLogActionCategory]: AuditLogAction.home_settings_update: AuditLogActionCategory.update, } # fmt: on - return lookup[self] + return lookup.get(self, None) @property def target_type(self) -> Optional[str]: From de09031899b994a27d39b96798b48d77501e465b Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 22 Aug 2025 19:18:33 -0400 Subject: [PATCH 105/138] Allow TextDisplay as a top-level component in modals --- discord/ui/modal.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 3900b49c22d8..a8787651febf 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -35,6 +35,7 @@ from .item import Item from .view import View from .label import Label +from .text_display import TextDisplay if TYPE_CHECKING: from typing_extensions import Self @@ -204,7 +205,7 @@ def key(item: Item) -> int: children = sorted(self._children, key=key) components: List[Dict[str, Any]] = [] for child in children: - if isinstance(child, Label): + if isinstance(child, (Label, TextDisplay)): components.append(child.to_component_dict()) # type: ignore else: # Every implicit child wrapped in an ActionRow in a modal From 6d5013030b5c4d01fd02bc3306704c659ccdb167 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 22 Aug 2025 19:28:37 -0400 Subject: [PATCH 106/138] Flip if statement for wrapped ActionRow components in modals --- discord/ui/modal.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index a8787651febf..cea93e6e67c2 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -34,8 +34,8 @@ from .._types import ClientT from .item import Item from .view import View -from .label import Label -from .text_display import TextDisplay +from .select import BaseSelect +from .text_input import TextInput if TYPE_CHECKING: from typing_extensions import Self @@ -205,9 +205,7 @@ def key(item: Item) -> int: children = sorted(self._children, key=key) components: List[Dict[str, Any]] = [] for child in children: - if isinstance(child, (Label, TextDisplay)): - components.append(child.to_component_dict()) # type: ignore - else: + if isinstance(child, (BaseSelect, TextInput)): # Every implicit child wrapped in an ActionRow in a modal # has a single child of width 5 # It's also deprecated to use ActionRow in modals @@ -217,6 +215,8 @@ def key(item: Item) -> int: 'components': [child.to_component_dict()], } ) + else: + components.append(child.to_component_dict()) return components From d00ac622bc5784ca47d42f37259dd224165b6e21 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 22 Aug 2025 19:52:12 -0400 Subject: [PATCH 107/138] Remove uses of deprecated Logger.warn method --- discord/ext/tasks/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ext/tasks/__init__.py b/discord/ext/tasks/__init__.py index 57f9e741bc6b..760bc74bd63d 100644 --- a/discord/ext/tasks/__init__.py +++ b/discord/ext/tasks/__init__.py @@ -229,7 +229,7 @@ async def _loop(self, *args: Any, **kwargs: Any) -> None: # Sometimes asyncio is cheeky and wakes up a few microseconds before our target # time, causing it to repeat a run. while self._is_explicit_time() and self._next_iteration <= self._last_iteration: - _log.warn( + _log.warning( ( 'Clock drift detected for task %s. Woke up at %s but needed to sleep until %s. ' 'Sleeping until %s again to correct clock' From ab6efd02bc6cca645cf750e412a62086e7d10e94 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 22 Aug 2025 19:59:56 -0400 Subject: [PATCH 108/138] [tasks] Log handled exceptions before sleeping Fix #10276 --- discord/ext/tasks/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/discord/ext/tasks/__init__.py b/discord/ext/tasks/__init__.py index 760bc74bd63d..6ed0273e3eb4 100644 --- a/discord/ext/tasks/__init__.py +++ b/discord/ext/tasks/__init__.py @@ -249,7 +249,14 @@ async def _loop(self, *args: Any, **kwargs: Any) -> None: self._last_iteration_failed = True if not self.reconnect: raise - await asyncio.sleep(backoff.delay()) + + retry_after = backoff.delay() + _log.exception( + 'Handling exception in internal background task %s. Retrying in %.2fs', + self.coro.__qualname__, + retry_after, + ) + await asyncio.sleep(retry_after) else: if self._stop_next_iteration: return From fe7ce982f332d421e12ddd0e1d4babb17c35c1d6 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sun, 24 Aug 2025 09:12:12 -0400 Subject: [PATCH 109/138] Add second generic parameter for view for DynamicItem --- discord/ui/dynamic.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/discord/ui/dynamic.py b/discord/ui/dynamic.py index faed8c37002d..d076267b1f4c 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -39,11 +39,14 @@ from ..components import Component from ..enums import ComponentType from .view import View, LayoutView + + V = TypeVar('V', bound=Union[View, LayoutView], covariant=True, default=Union[View, LayoutView]) else: View = LayoutView = Any + from .item import V -class DynamicItem(Generic[BaseT], Item[Union[View, LayoutView]]): +class DynamicItem(Generic[BaseT, V], Item[V]): """Represents an item with a dynamic ``custom_id`` that can be used to store state within that ``custom_id``. From bb45e050b9f46d5bb3d530d890a0966ef4e9ec99 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sun, 24 Aug 2025 09:29:21 -0400 Subject: [PATCH 110/138] Change Modal inheritance to BaseView instead of View --- discord/ui/modal.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index cea93e6e67c2..4a58dcc06610 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -33,7 +33,7 @@ from ..utils import MISSING, find from .._types import ClientT from .item import Item -from .view import View +from .view import BaseView from .select import BaseSelect from .text_input import TextInput @@ -54,7 +54,7 @@ _log = logging.getLogger(__name__) -class Modal(View): +class Modal(BaseView): """Represents a UI modal. This object must be inherited to create a modal popup window within discord. From 753db1724bf13add78b801ebc3952be65e5e7d46 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sun, 24 Aug 2025 09:21:28 -0400 Subject: [PATCH 111/138] Add changelog for v2.6.1 --- docs/whats_new.rst | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/whats_new.rst b/docs/whats_new.rst index c5c7fa2a93d0..25011bb4b8ca 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -11,6 +11,21 @@ Changelog This page keeps a detailed human friendly rendering of what's new and changed in specific versions. +.. _vp2p6p1: + +v2.6.1 +------- + +Bug Fixes +~~~~~~~~~~ + +- Fix :attr:`ui.Section.children` and :attr:`ui.Section.accessory` having ``None`` as the :attr:`Item.parent` (:issue:`10269`) +- Fix error when using a :class:`ui.DynamicItem` inside an :class:`ui.Section` +- Fix :class:`ui.DynamicItem` not working when set as an :attr:`ui.Section.acessory` (:issue:`10271`) +- Fix :attr:`ui.LayoutView.total_children_count` being inaccurate when adding nested items +- Fix crash when accessing :attr:`AuditLogEntry.category` for unknown audit log actions +- |tasks| Add logging statement when a handled exception occurs (:issue:`10276`) + .. _vp2p6p0: v2.6.0 From 9677dada1fbe22f495146ed3ea32dff774b7d047 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sun, 24 Aug 2025 09:41:49 -0400 Subject: [PATCH 112/138] Fix Modal not raising when hitting the 5 item limit --- discord/ui/modal.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 4a58dcc06610..c85719a9befd 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -235,3 +235,8 @@ def to_dict(self) -> Dict[str, Any]: } return payload + + def add_item(self, item: Item[Any]) -> Self: + if len(self._children) >= 5: + raise ValueError('maximum number of children exceeded (5)') + return super().add_item(item) From 63357e011333870e937fab564620f467b6150e76 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sun, 24 Aug 2025 13:21:54 -0400 Subject: [PATCH 113/138] Revert "Add second generic parameter for view for DynamicItem" This reverts commit fe7ce982f332d421e12ddd0e1d4babb17c35c1d6. --- discord/ui/dynamic.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/discord/ui/dynamic.py b/discord/ui/dynamic.py index d076267b1f4c..faed8c37002d 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -39,14 +39,11 @@ from ..components import Component from ..enums import ComponentType from .view import View, LayoutView - - V = TypeVar('V', bound=Union[View, LayoutView], covariant=True, default=Union[View, LayoutView]) else: View = LayoutView = Any - from .item import V -class DynamicItem(Generic[BaseT, V], Item[V]): +class DynamicItem(Generic[BaseT], Item[Union[View, LayoutView]]): """Represents an item with a dynamic ``custom_id`` that can be used to store state within that ``custom_id``. From 50b0d219b50ae4a60e7d85252da633ff73f005be Mon Sep 17 00:00:00 2001 From: lmaotrigine <57328245+lmaotrigine@users.noreply.github.com> Date: Sun, 24 Aug 2025 22:52:35 +0530 Subject: [PATCH 114/138] Add missing versionadded to some BaseView items --- discord/ui/view.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index cf6b495f0234..cbf40a14d139 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -335,7 +335,9 @@ def children(self) -> List[Item[Self]]: @property def total_children_count(self) -> int: - """:class:`int`: The total number of children in this view, including those from nested items.""" + """:class:`int`: The total number of children in this view, including those from nested items. + + .. versionadded:: 2.6""" return self._total_children @classmethod @@ -655,6 +657,8 @@ def walk_children(self) -> Generator[Item[Any], None, None]: """An iterator that recursively walks through all the children of this view and its children, if applicable. + .. versionadded:: 2.6 + Yields ------ :class:`Item` From 91281f23a00737e7152612a2f7c2e199d9636e22 Mon Sep 17 00:00:00 2001 From: Jakub Kuczys Date: Mon, 25 Aug 2025 01:59:10 +0200 Subject: [PATCH 115/138] Add Set Voice Channel Status permission --- discord/permissions.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/discord/permissions.py b/discord/permissions.py index 729763785e82..a1e0d21c2c6d 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -88,6 +88,7 @@ class _BasePermissionsKwargs(Generic[BoolOrNoneT], TypedDict, total=False): use_soundboard: BoolOrNoneT use_external_sounds: BoolOrNoneT send_voice_messages: BoolOrNoneT + set_voice_channel_status: BoolOrNoneT create_expressions: BoolOrNoneT create_events: BoolOrNoneT send_polls: BoolOrNoneT @@ -252,7 +253,7 @@ def all(cls) -> Self: permissions set to ``True``. """ # Some of these are 0 because we don't want to set unnecessary bits - return cls(0b0000_0000_0000_1110_0111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111) + return cls(0b0000_0000_0000_1111_0111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111) @classmethod def _timeout_mask(cls) -> int: @@ -383,8 +384,12 @@ def text(cls) -> Self: @classmethod def voice(cls) -> Self: """A factory method that creates a :class:`Permissions` with all - "Voice" permissions from the official Discord UI set to ``True``.""" - return cls(0b0000_0000_0000_0000_0010_0100_1000_0000_0000_0011_1111_0000_0000_0011_0000_0000) + "Voice" permissions from the official Discord UI set to ``True``. + + .. versionchanged:: 2.7 + Added :attr:`set_voice_channel_status` permission. + """ + return cls(0b0000_0000_0000_0001_0010_0100_1000_0000_0000_0011_1111_0000_0000_0011_0000_0000) @classmethod def stage(cls) -> Self: @@ -839,6 +844,14 @@ def send_voice_messages(self) -> int: """ return 1 << 46 + @flag_value + def set_voice_channel_status(self) -> int: + """:class:`bool`: Returns ``True`` if a user can set voice channel status. + + .. versionadded:: 2.7 + """ + return 1 << 48 + @flag_value def send_polls(self) -> int: """:class:`bool`: Returns ``True`` if a user can send poll messages. @@ -989,6 +1002,7 @@ class PermissionOverwrite: use_soundboard: Optional[bool] use_external_sounds: Optional[bool] send_voice_messages: Optional[bool] + set_voice_channel_status: Optional[bool] create_expressions: Optional[bool] create_events: Optional[bool] send_polls: Optional[bool] From d5946d1cbc78bd90b3c1ca5cc84e8f638f3f75dc Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 25 Aug 2025 11:29:03 -0400 Subject: [PATCH 116/138] Fix bounds for TextInput and Label generic Fix #10280 --- discord/ui/label.py | 4 ++-- discord/ui/text_input.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/discord/ui/label.py b/discord/ui/label.py index 9357de425471..7a2d496a6071 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -35,7 +35,7 @@ from typing_extensions import Self from ..types.components import LabelComponent as LabelComponentPayload - from .view import View + from .view import BaseView # fmt: off @@ -44,7 +44,7 @@ ) # fmt: on -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='BaseView', covariant=True) class Label(Item[V]): diff --git a/discord/ui/text_input.py b/discord/ui/text_input.py index 288e5efdcb8c..de0c8e079364 100644 --- a/discord/ui/text_input.py +++ b/discord/ui/text_input.py @@ -37,7 +37,7 @@ from ..types.components import TextInput as TextInputPayload from ..types.interactions import ModalSubmitTextInputInteractionData as ModalSubmitTextInputInteractionDataPayload - from .view import View + from .view import BaseView from ..interactions import Interaction @@ -47,7 +47,7 @@ ) # fmt: on -V = TypeVar('V', bound='View', covariant=True) +V = TypeVar('V', bound='BaseView', covariant=True) class TextInput(Item[V]): From 69f06c945620c581847531088734b05f7752474f Mon Sep 17 00:00:00 2001 From: z03h <7235242+z03h@users.noreply.github.com> Date: Mon, 25 Aug 2025 20:24:37 -0700 Subject: [PATCH 117/138] Fix Select.required not being applied --- discord/components.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/components.py b/discord/components.py index 0986680fc792..d5a1737a22db 100644 --- a/discord/components.py +++ b/discord/components.py @@ -396,6 +396,7 @@ def to_dict(self) -> SelectMenuPayload: 'min_values': self.min_values, 'max_values': self.max_values, 'disabled': self.disabled, + 'required': self.required, } if self.id is not None: payload['id'] = self.id From cbff6ddef9891401c53ad338315158ff8aa090c9 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Tue, 26 Aug 2025 09:34:18 +0200 Subject: [PATCH 118/138] Add support for user collectibles --- discord/__init__.py | 1 + discord/asset.py | 10 ++++ discord/collectible.py | 109 +++++++++++++++++++++++++++++++++++++++++ discord/enums.py | 20 ++++++++ discord/member.py | 2 + discord/types/user.py | 41 +++++++++++----- discord/user.py | 16 ++++++ docs/api.rst | 70 +++++++++++++++++++++++++- 8 files changed, 256 insertions(+), 13 deletions(-) create mode 100644 discord/collectible.py diff --git a/discord/__init__.py b/discord/__init__.py index f4d7af42ee76..3279f8b8c048 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -75,6 +75,7 @@ from .presences import * from .primary_guild import * from .onboarding import * +from .collectible import * class VersionInfo(NamedTuple): diff --git a/discord/asset.py b/discord/asset.py index a3ed53c6bcbc..41bcba3cf186 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -355,6 +355,16 @@ def _from_primary_guild(cls, state: _State, guild_id: int, icon_hash: str) -> Se animated=False, ) + @classmethod + def _from_user_collectible(cls, state: _State, asset: str, animated: bool = False) -> Self: + name = 'static.png' if not animated else 'asset.webm' + return cls( + state, + url=f'{cls.BASE}/assets/collectibles/{asset}{name}', + key=asset, + animated=animated, + ) + def __str__(self) -> str: return self._url diff --git a/discord/collectible.py b/discord/collectible.py new file mode 100644 index 000000000000..b2ad7e4e01ba --- /dev/null +++ b/discord/collectible.py @@ -0,0 +1,109 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +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. +""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + + +from .asset import Asset +from .enums import NameplatePalette, CollectibleType, try_enum +from .utils import parse_time + + +if TYPE_CHECKING: + from datetime import datetime + + from .state import ConnectionState + from .types.user import ( + Collectible as CollectiblePayload, + ) + + +__all__ = ('Collectible',) + + +class Collectible: + """Represents a user's collectible. + + .. versionadded:: 2.7 + + Attributes + ---------- + label: :class:`str` + The label of the collectible. + palette: Optional[:class:`NameplatePalette`] + The palette of the collectible. + This is only available if ``type`` is + :class:`CollectibleType.nameplate`. + sku_id: :class:`int` + The SKU ID of the collectible. + type: :class:`CollectibleType` + The type of the collectible. + expires_at: Optional[:class:`datetime.datetime`] + The expiration date of the collectible. If applicable. + """ + + __slots__ = ( + 'type', + 'sku_id', + 'label', + 'expires_at', + 'palette', + '_state', + '_asset', + ) + + def __init__(self, *, state: ConnectionState, type: str, data: CollectiblePayload) -> None: + self._state: ConnectionState = state + self.type: CollectibleType = try_enum(CollectibleType, type) + self._asset: str = data['asset'] + self.sku_id: int = int(data['sku_id']) + self.label: str = data['label'] + self.expires_at: Optional[datetime] = parse_time(data.get('expires_at')) + + # nameplate + self.palette: Optional[NameplatePalette] + try: + self.palette = try_enum(NameplatePalette, data['palette']) # type: ignore + except KeyError: + self.palette = None + + @property + def static(self) -> Asset: + """:class:`Asset`: The static asset of the collectible.""" + return Asset._from_user_collectible(self._state, self._asset) + + @property + def animated(self) -> Asset: + """:class:`Asset`: The animated asset of the collectible.""" + return Asset._from_user_collectible(self._state, self._asset, animated=True) + + def __repr__(self) -> str: + attrs = ['sku_id'] + if self.palette: + attrs.append('palette') + + joined_attrs = ' '.join(f'{attr}={getattr(self, attr)!r}' for attr in attrs) + return f'<{self.type.name.title()} {joined_attrs}>' diff --git a/discord/enums.py b/discord/enums.py index 7dc4bccd05a4..28b99ab031e8 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -83,6 +83,8 @@ 'OnboardingMode', 'SeparatorSpacing', 'MediaItemLoadingState', + 'CollectibleType', + 'NameplatePalette', ) @@ -968,6 +970,24 @@ class MediaItemLoadingState(Enum): not_found = 3 +class CollectibleType(Enum): + nameplate = 'nameplate' + + +class NameplatePalette(Enum): + crimson = 'crimson' + berry = 'berry' + sky = 'sky' + teal = 'teal' + forest = 'forest' + bubble_gum = 'bubble_gum' + violet = 'violet' + cobalt = 'cobalt' + clover = 'clover' + lemon = 'lemon' + white = 'white' + + def create_unknown_value(cls: Type[E], val: Any) -> E: value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below name = f'unknown_{val}' diff --git a/discord/member.py b/discord/member.py index 9f6b9daf24bf..fd2cf7edba15 100644 --- a/discord/member.py +++ b/discord/member.py @@ -75,6 +75,7 @@ VoiceState as VoiceStatePayload, ) from .primary_guild import PrimaryGuild + from .collectible import Collectible VocalGuildChannel = Union[VoiceChannel, StageChannel] @@ -311,6 +312,7 @@ class Member(discord.abc.Messageable, _UserTag): avatar_decoration: Optional[Asset] avatar_decoration_sku_id: Optional[int] primary_guild: PrimaryGuild + collectibles: List[Collectible] def __init__(self, *, data: MemberWithUserPayload, guild: Guild, state: ConnectionState): self._state: ConnectionState = state diff --git a/discord/types/user.py b/discord/types/user.py index b2b213ecf6d0..639384a5684f 100644 --- a/discord/types/user.py +++ b/discord/types/user.py @@ -27,11 +27,38 @@ from typing_extensions import NotRequired -class AvatarDecorationData(TypedDict): +PremiumType = Literal[0, 1, 2, 3] +NameplatePallete = Literal['crimson', 'berry', 'sky', 'teal', 'forest', 'bubble_gum', 'violet', 'cobalt', 'clover'] + + +class _UserSKU(TypedDict): asset: str sku_id: Snowflake +AvatarDecorationData = _UserSKU + + +class PrimaryGuild(TypedDict): + identity_guild_id: Optional[int] + identity_enabled: Optional[bool] + tag: Optional[str] + badge: Optional[str] + + +class Collectible(_UserSKU): + label: str + expires_at: Optional[str] + + +class NameplateCollectible(Collectible): + palette: str + + +class UserCollectibles(TypedDict): + nameplate: NameplateCollectible + + class PartialUser(TypedDict): id: Snowflake username: str @@ -39,9 +66,8 @@ class PartialUser(TypedDict): avatar: Optional[str] global_name: Optional[str] avatar_decoration_data: NotRequired[AvatarDecorationData] - - -PremiumType = Literal[0, 1, 2, 3] + primary_guild: NotRequired[PrimaryGuild] + collectibles: NotRequired[UserCollectibles] class User(PartialUser, total=False): @@ -54,10 +80,3 @@ class User(PartialUser, total=False): flags: int premium_type: PremiumType public_flags: int - - -class PrimaryGuild(TypedDict): - identity_guild_id: Optional[int] - identity_enabled: Optional[bool] - tag: Optional[str] - badge: Optional[str] diff --git a/discord/user.py b/discord/user.py index 751437532c3a..32edb1dc74d3 100644 --- a/discord/user.py +++ b/discord/user.py @@ -33,6 +33,7 @@ from .flags import PublicUserFlags from .utils import snowflake_time, _bytes_to_base64_data, MISSING, _get_as_snowflake from .primary_guild import PrimaryGuild +from .collectible import Collectible if TYPE_CHECKING: from typing_extensions import Self @@ -49,6 +50,7 @@ User as UserPayload, AvatarDecorationData, PrimaryGuild as PrimaryGuildPayload, + UserCollectibles as UserCollectiblesPayload, ) @@ -78,6 +80,7 @@ class BaseUser(_UserTag): '_state', '_avatar_decoration_data', '_primary_guild', + '_collectibles', ) if TYPE_CHECKING: @@ -94,6 +97,7 @@ class BaseUser(_UserTag): _public_flags: int _avatar_decoration_data: Optional[AvatarDecorationData] _primary_guild: Optional[PrimaryGuildPayload] + _collectibles: Optional[UserCollectiblesPayload] def __init__(self, *, state: ConnectionState, data: Union[UserPayload, PartialUserPayload]) -> None: self._state = state @@ -132,6 +136,7 @@ def _update(self, data: Union[UserPayload, PartialUserPayload]) -> None: self.system = data.get('system', False) self._avatar_decoration_data = data.get('avatar_decoration_data') self._primary_guild = data.get('primary_guild', None) + self._collectibles = data.get('collectibles', None) @classmethod def _copy(cls, user: Self) -> Self: @@ -149,6 +154,7 @@ def _copy(cls, user: Self) -> Self: self._public_flags = user._public_flags self._avatar_decoration_data = user._avatar_decoration_data self._primary_guild = user._primary_guild + self._collectibles = user._collectibles return self @@ -324,6 +330,16 @@ def primary_guild(self) -> PrimaryGuild: return PrimaryGuild(state=self._state, data=self._primary_guild) return PrimaryGuild._default(self._state) + @property + def collectibles(self) -> List[Collectible]: + """List[:class:`Collectible`]: Returns a list of the user's collectibles. + + .. versionadded:: 2.7 + """ + if self._collectibles is None: + return [] + return [Collectible(state=self._state, type=key, data=value) for key, value in self._collectibles.items() if value] # type: ignore + def mentioned_in(self, message: Message) -> bool: """Checks if the user is mentioned in the specified message. diff --git a/docs/api.rst b/docs/api.rst index 53ddec06eecd..1a564ddcad5b 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4052,8 +4052,6 @@ of :class:`enum.Enum`. Default channels and questions count towards onboarding constraints. - - .. class:: MediaItemLoadingState Represents a :class:`UnfurledMediaItem` load state. @@ -4074,6 +4072,66 @@ of :class:`enum.Enum`. The media item was not found. +.. class:: CollectibleType + + Represents the type of a :class:`Collectible`. + + .. versionadded:: 2.7 + + .. attribute:: nameplate + + The collectible is a nameplate. + +.. class:: NameplatePalette + + Represents the available palettes for a nameplate. + + .. versionadded:: 2.7 + + .. attribute:: crimson + + The collectible nameplate palette is crimson. + + .. attribute:: berry + + The collectible nameplate palette is berry. + + .. attribute:: sky + + The collectible nameplate palette is sky. + + .. attribute:: teal + + The collectible nameplate palette is teal. + + .. attribute:: forest + + The collectible nameplate palette is forest. + + .. attribute:: bubble_gum + + The collectible nameplate palette is bubble gum. + + .. attribute:: violet + + The collectible nameplate palette is violet. + + .. attribute:: cobalt + + The collectible nameplate palette is cobalt. + + .. attribute:: clover + + The collectible nameplate palette is clover. + + .. attribute:: lemon + + The collectible nameplate palette is lemon. + + .. attribute:: white + + The collectible nameplate palette is white. + .. _discord-api-audit-logs: Audit Log Data @@ -5770,6 +5828,14 @@ PrimaryGuild .. autoclass:: PrimaryGuild() :members: +Collectible +~~~~~~~~~~~ + +.. attributetable:: Collectible + +.. autoclass:: Collectible() + :members: + CallMessage ~~~~~~~~~~~~~~~~~~~ From 7c52dbdba01bf2572db862381c9949d61de08f22 Mon Sep 17 00:00:00 2001 From: Sacul Date: Tue, 26 Aug 2025 23:34:23 +0800 Subject: [PATCH 119/138] Fix spelling mistake in LabelComponent slots --- discord/components.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/components.py b/discord/components.py index d5a1737a22db..a13a214f7101 100644 --- a/discord/components.py +++ b/discord/components.py @@ -1355,11 +1355,11 @@ class LabelComponent(Component): __slots__ = ( 'label', 'description', - 'commponent', + 'component', 'id', ) - __repr_info__ = ('label', 'description', 'commponent', 'id,') + __repr_info__ = ('label', 'description', 'component', 'id,') def __init__(self, data: LabelComponentPayload, state: Optional[ConnectionState]) -> None: self.component: Component = _component_factory(data['component'], state) # type: ignore From 116107d7ede689b1fff977b4bd89309f09ad9be6 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 26 Aug 2025 11:53:55 -0400 Subject: [PATCH 120/138] Add accessory to Section.__repr__ --- discord/ui/section.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/section.py b/discord/ui/section.py index 6a4026e22a88..cb914612d840 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -87,7 +87,7 @@ def __init__( self.id = id def __repr__(self) -> str: - return f'<{self.__class__.__name__} children={len(self._children)}>' + return f'<{self.__class__.__name__} children={len(self._children)} accessory={self._accessory!r}>' @property def type(self) -> Literal[ComponentType.section]: From 2f1f5fe2e2bd7c20280275a0d3dee7a0662d2f96 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 26 Aug 2025 21:12:03 -0400 Subject: [PATCH 121/138] Revert "Fix context install decorators to correctly restrict commands" This reverts commit ce9f5ad1ba07d070a50bf41b9d26f68a85be610b. --- discord/app_commands/commands.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/discord/app_commands/commands.py b/discord/app_commands/commands.py index a23682f8b23f..36d07d41c4e6 100644 --- a/discord/app_commands/commands.py +++ b/discord/app_commands/commands.py @@ -2523,10 +2523,7 @@ def inner(f: T) -> T: allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext() f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment - # Ensure that only Guild context is allowed - allowed_contexts.guild = True # Enable guild context - allowed_contexts.private_channel = False # Disable private channel context - allowed_contexts.dm_channel = False # Disable DM context + allowed_contexts.guild = True return f @@ -2578,10 +2575,7 @@ def inner(f: T) -> T: allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext() f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment - # Ensure that only Private Channel context is allowed - allowed_contexts.guild = False # Disable guild context - allowed_contexts.private_channel = True # Enable private channel context - allowed_contexts.dm_channel = False # Disable DM context + allowed_contexts.private_channel = True return f @@ -2631,11 +2625,7 @@ def inner(f: T) -> T: allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext() f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment - # Ensure that only DM context is allowed - allowed_contexts.guild = False # Disable guild context - allowed_contexts.private_channel = False # Disable private channel context - allowed_contexts.dm_channel = True # Enable DM context - + allowed_contexts.dm_channel = True return f # Check if called with parentheses or not @@ -2727,7 +2717,6 @@ def inner(f: T) -> T: f.__discord_app_commands_installation_types__ = allowed_installs # type: ignore # Runtime attribute assignment allowed_installs.guild = True - allowed_installs.user = False return f @@ -2776,7 +2765,6 @@ def inner(f: T) -> T: f.__discord_app_commands_installation_types__ = allowed_installs # type: ignore # Runtime attribute assignment allowed_installs.user = True - allowed_installs.guild = False return f From 89d5cbd78a19dfce3d25dad488eee4a778faf519 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sun, 31 Aug 2025 14:18:50 -0400 Subject: [PATCH 122/138] Move v2 item check from BaseView to View --- discord/ui/view.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index cbf40a14d139..9c7547e60760 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -426,9 +426,6 @@ def add_item(self, item: Item[Any]) -> Self: if not isinstance(item, Item): raise TypeError(f'expected Item not {item.__class__.__name__}') - if item._is_v2() and not self._is_layout(): - raise ValueError('v2 items cannot be added to this view') - item._update_view(self) self._add_count(item._total_count) self._children.append(item) @@ -737,6 +734,9 @@ def add_item(self, item: Item[Any]) -> Self: if len(self._children) >= 25: raise ValueError('maximum number of children exceeded') + if item._is_v2(): + raise ValueError('v2 items cannot be added to this view') + super().add_item(item) try: self.__weights.add_item(item) From 6c4f8c410d9aec5d1e2b001db9c0acfe8a22048a Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 27 Aug 2025 23:41:00 -0400 Subject: [PATCH 123/138] Add changelog for v2.6.2 and v2.6.3 --- docs/whats_new.rst | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/whats_new.rst b/docs/whats_new.rst index 25011bb4b8ca..c78c44435b4c 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -11,6 +11,28 @@ Changelog This page keeps a detailed human friendly rendering of what's new and changed in specific versions. +.. _vp2p6p3: + +v2.6.3 +------- + +Bug Fixes +~~~~~~~~~~ + +- Fix :attr:`ui.Select.required` not being applied properly +- Fix potential attribute error when accessing :class:`LabelComponent` +- Fix issue when stacking decorators such as :func:`app_commands.guild_install` and :func:`app_commands.user_install` + +.. _vp2p6p2: + +v2.6.2 +------- + +Bug Fixes +~~~~~~~~~~ + +- Fix a bug with :class:`ui.DynamicItem` causing it to fail at runtime when passing a generic. + .. _vp2p6p1: v2.6.1 From 25a4dbe86aa350b7f7cf58ac8279b158e6489dcd Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sun, 31 Aug 2025 16:00:59 -0400 Subject: [PATCH 124/138] Remove id from LabelComponent.__repr__ --- discord/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/components.py b/discord/components.py index a13a214f7101..08ae4f2773e6 100644 --- a/discord/components.py +++ b/discord/components.py @@ -1359,7 +1359,7 @@ class LabelComponent(Component): 'id', ) - __repr_info__ = ('label', 'description', 'component', 'id,') + __repr_info__ = ('label', 'description', 'component') def __init__(self, data: LabelComponentPayload, state: Optional[ConnectionState]) -> None: self.component: Component = _component_factory(data['component'], state) # type: ignore From 1e165eebd95c33a66a68cf6031eb30a771f93ec5 Mon Sep 17 00:00:00 2001 From: blord0 <68508813+blord0@users.noreply.github.com> Date: Tue, 2 Sep 2025 22:05:25 +0100 Subject: [PATCH 125/138] Update docs for on_user_update to mention primary_guild --- docs/api.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api.rst b/docs/api.rst index 1a564ddcad5b..1b762ab5104d 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -874,6 +874,7 @@ Members - avatar - username - discriminator + - primary guild This requires :attr:`Intents.members` to be enabled. From 44956db0331850699902b8953f871d309418542e Mon Sep 17 00:00:00 2001 From: Sacul Date: Wed, 3 Sep 2025 05:05:50 +0800 Subject: [PATCH 126/138] Update modal example in docstring --- discord/ui/modal.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index c85719a9befd..86c09da3086d 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -70,11 +70,11 @@ class Modal(BaseView): from discord import ui class Questionnaire(ui.Modal, title='Questionnaire Response'): - name = ui.TextInput(label='Name') - answer = ui.TextInput(label='Answer', style=discord.TextStyle.paragraph) + name = ui.Label(text='Name', component=ui.TextInput()) + answer = ui.Label(text='Answer', component=ui.TextInput(style=discord.TextStyle.paragraph)) async def on_submit(self, interaction: discord.Interaction): - await interaction.response.send_message(f'Thanks for your response, {self.name}!', ephemeral=True) + await interaction.response.send_message(f'Thanks for your response, {self.name.component.value}!', ephemeral=True) Parameters ----------- From 3f47698791412f40a93ac52034cb6ae0411e99cf Mon Sep 17 00:00:00 2001 From: Steve C Date: Wed, 3 Sep 2025 05:01:50 -0400 Subject: [PATCH 127/138] All __all__ to primary_guilds module --- discord/primary_guild.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/discord/primary_guild.py b/discord/primary_guild.py index 85e40159a40a..940cb77c8c9e 100644 --- a/discord/primary_guild.py +++ b/discord/primary_guild.py @@ -35,6 +35,12 @@ from .types.user import PrimaryGuild as PrimaryGuildPayload from typing_extensions import Self +# fmt: off +__all__ = ( + 'PrimaryGuild', +) +# fmt: on + class PrimaryGuild: """Represents the primary guild identity of a :class:`User` From a7f349498a9ca711b26de6f7a0415944ee259796 Mon Sep 17 00:00:00 2001 From: Lucas Hardt Date: Wed, 3 Sep 2025 11:31:59 +0200 Subject: [PATCH 128/138] Upgrade code by using f-strings and yield from --- discord/message.py | 8 ++++---- discord/ui/action_row.py | 3 +-- discord/ui/section.py | 3 +-- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/discord/message.py b/discord/message.py index 02f31198d9e9..2c7ab17a7668 100644 --- a/discord/message.py +++ b/discord/message.py @@ -2843,14 +2843,14 @@ def system_content(self) -> str: if call_ended: duration = utils._format_call_duration(self.call.duration) # type: ignore # call can't be None here if missed: - return 'You missed a call from {0.author.name} that lasted {1}.'.format(self, duration) + return f'You missed a call from {self.author.name} that lasted {duration}.' else: - return '{0.author.name} started a call that lasted {1}.'.format(self, duration) + return f'{self.author.name} started a call that lasted {duration}.' else: if missed: - return '{0.author.name} started a call. \N{EM DASH} Join the call'.format(self) + return f'{self.author.name} started a call. \N{EM DASH} Join the call' else: - return '{0.author.name} started a call.'.format(self) + return f'{self.author.name} started a call.' if self.type is MessageType.purchase_notification and self.purchase_notification is not None: guild_product_purchase = self.purchase_notification.guild_product_purchase diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index b0598db2015b..c7f7a2b7b9a3 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -227,8 +227,7 @@ def walk_children(self) -> Generator[Item[V], Any, None]: An item in the action row. """ - for child in self.children: - yield child + yield from self.children def content_length(self) -> int: """:class:`int`: Returns the total length of all text content in this action row.""" diff --git a/discord/ui/section.py b/discord/ui/section.py index cb914612d840..67d35e001390 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -137,8 +137,7 @@ def walk_children(self) -> Generator[Item[V], None, None]: An item in this section. """ - for child in self.children: - yield child + yield from self.children yield self.accessory def _update_view(self, view) -> None: From fe4bf87b232c0b870b92781b4249c7fdd49200d1 Mon Sep 17 00:00:00 2001 From: Sacul Date: Wed, 3 Sep 2025 17:33:41 +0800 Subject: [PATCH 129/138] Add channel attribute to automod quarantine user AuditLogAction --- discord/audit_logs.py | 14 +------------- docs/api.rst | 3 ++- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/discord/audit_logs.py b/discord/audit_logs.py index c27a793c360c..e56f0fb3d252 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -630,11 +630,6 @@ class _AuditLogProxyAutoModAction(_AuditLogProxy): channel: Optional[Union[abc.GuildChannel, Thread]] -class _AuditLogProxyAutoModActionQuarantineUser(_AuditLogProxy): - automod_rule_name: str - automod_rule_trigger_type: str - - class _AuditLogProxyMemberKickOrMemberRoleUpdate(_AuditLogProxy): integration_type: Optional[str] @@ -725,7 +720,6 @@ def _from_data(self, data: AuditLogEntryPayload) -> None: _AuditLogProxyStageInstanceAction, _AuditLogProxyMessageBulkDelete, _AuditLogProxyAutoModAction, - _AuditLogProxyAutoModActionQuarantineUser, _AuditLogProxyMemberKickOrMemberRoleUpdate, Member, User, None, PartialIntegration, Role, Object @@ -766,6 +760,7 @@ def _from_data(self, data: AuditLogEntryPayload) -> None: self.action is enums.AuditLogAction.automod_block_message or self.action is enums.AuditLogAction.automod_flag_message or self.action is enums.AuditLogAction.automod_timeout_member + or self.action is enums.AuditLogAction.automod_quarantine_user ): channel_id = utils._get_as_snowflake(extra, 'channel_id') channel = None @@ -781,13 +776,6 @@ def _from_data(self, data: AuditLogEntryPayload) -> None: ), channel=channel, ) - elif self.action is enums.AuditLogAction.automod_quarantine_user: - self.extra = _AuditLogProxyAutoModActionQuarantineUser( - automod_rule_name=extra['auto_moderation_rule_name'], - automod_rule_trigger_type=enums.try_enum( - enums.AutoModRuleTriggerType, int(extra['auto_moderation_rule_trigger_type']) - ), - ) elif self.action.name.startswith('overwrite_'): # the overwrite_ actions have a dict with some information diff --git a/docs/api.rst b/docs/api.rst index 1b762ab5104d..428d9e1d954b 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3064,10 +3064,11 @@ of :class:`enum.Enum`. a :class:`Member` with the ID of the person who triggered the automod rule. When this is the action, the type of :attr:`~AuditLogEntry.extra` is - set to an unspecified proxy object with 2 attributes: + set to an unspecified proxy object with 3 attributes: - ``automod_rule_name``: The name of the automod rule that was triggered. - ``automod_rule_trigger_type``: A :class:`AutoModRuleTriggerType` representation of the rule type that was triggered. + - ``channel``: The channel of the message sent by the member when they were flagged. `None` if the member was quarantined when they just joined the guild. When this is the action, :attr:`AuditLogEntry.changes` is empty. From bd329b15159976ca0a5c8da8fe12bd73ee0c23b3 Mon Sep 17 00:00:00 2001 From: Steve C Date: Wed, 3 Sep 2025 05:34:13 -0400 Subject: [PATCH 130/138] Add support for emoji_added message type --- discord/enums.py | 1 + discord/flags.py | 9 +++++++++ discord/message.py | 3 +++ discord/types/message.py | 1 + docs/api.rst | 8 ++++++++ 5 files changed, 22 insertions(+) diff --git a/discord/enums.py b/discord/enums.py index 28b99ab031e8..172f736a9adc 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -275,6 +275,7 @@ class MessageType(Enum): guild_incident_report_false_alarm = 39 purchase_notification = 44 poll_result = 46 + emoji_added = 63 class SpeakingState(Enum): diff --git a/discord/flags.py b/discord/flags.py index 5105a4156c30..a4878368c963 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -371,6 +371,15 @@ def role_subscription_purchase_notification_replies(self): """ return 32 + @flag_value + def emoji_added(self): + """:class:`bool`: Returns ``True`` if the system channel is used for + emoji added notifications. + + .. versionadded:: 2.7 + """ + return 256 + @fill_with_flags() class MessageFlags(BaseFlags): diff --git a/discord/message.py b/discord/message.py index 2c7ab17a7668..9db351d54a80 100644 --- a/discord/message.py +++ b/discord/message.py @@ -2865,6 +2865,9 @@ def system_content(self) -> str: ) return f"{self.author.display_name}'s poll {poll_title.value} has closed." # type: ignore + if self.type is MessageType.emoji_added: + return f'{self.author.name} added a new emoji, {self.content}' + # Fallback for unknown message types return '' diff --git a/discord/types/message.py b/discord/types/message.py index dfb251f28355..c7631ffc37f9 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -175,6 +175,7 @@ class CallMessage(TypedDict): 39, 44, 46, + 63, ] diff --git a/docs/api.rst b/docs/api.rst index 428d9e1d954b..ab8f4f5caaad 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1925,6 +1925,14 @@ of :class:`enum.Enum`. The system message sent when a poll has closed. + .. versionadded:: 2.5 + + .. attribute:: emoji_added + + The system message sent when a custom emoji is added to the guild. + + .. versionadded:: 2.7 + .. class:: UserFlags Represents Discord User flags. From a2a228105b5c98e0f3f9da2735639aa00cb56694 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sun, 28 Sep 2025 18:03:50 +0200 Subject: [PATCH 131/138] Fix various TypeDicts for Unpack --- discord/client.py | 20 ++++++++++---------- discord/ext/commands/bot.py | 4 ++-- discord/ext/commands/cog.py | 17 +++-------------- discord/ext/commands/core.py | 10 +++++----- discord/ext/commands/help.py | 10 +++++----- discord/ext/commands/hybrid.py | 4 +++- 6 files changed, 28 insertions(+), 37 deletions(-) diff --git a/discord/client.py b/discord/client.py index cfd8fb1225a8..63c86f352485 100644 --- a/discord/client.py +++ b/discord/client.py @@ -124,25 +124,25 @@ from .flags import MemberCacheFlags class _ClientOptions(TypedDict, total=False): - max_messages: int - proxy: str - proxy_auth: aiohttp.BasicAuth - shard_id: int - shard_count: int + max_messages: Optional[int] + proxy: Optional[str] + proxy_auth: Optional[aiohttp.BasicAuth] + shard_id: Optional[int] + shard_count: Optional[int] application_id: int member_cache_flags: MemberCacheFlags chunk_guilds_at_startup: bool - status: Status - activity: BaseActivity - allowed_mentions: AllowedMentions + status: Optional[Status] + activity: Optional[BaseActivity] + allowed_mentions: Optional[AllowedMentions] heartbeat_timeout: float guild_ready_timeout: float assume_unsync_clock: bool enable_debug_events: bool enable_raw_presences: bool http_trace: aiohttp.TraceConfig - max_ratelimit_timeout: float - connector: aiohttp.BaseConnector + max_ratelimit_timeout: Optional[float] + connector: Optional[aiohttp.BaseConnector] # fmt: off diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 3a916d69e0b4..0bb4cf95f5ed 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -89,8 +89,8 @@ PrefixType = Union[_Prefix, _PrefixCallable[BotT]] class _BotOptions(_ClientOptions, total=False): - owner_id: int - owner_ids: Collection[int] + owner_id: Optional[int] + owner_ids: Optional[Collection[int]] strip_after_prefix: bool case_insensitive: bool diff --git a/discord/ext/commands/cog.py b/discord/ext/commands/cog.py index 371a9f8c1047..b6d2ab0c1805 100644 --- a/discord/ext/commands/cog.py +++ b/discord/ext/commands/cog.py @@ -45,29 +45,18 @@ Tuple, TypeVar, Union, - TypedDict, ) from ._types import _BaseCommand, BotT if TYPE_CHECKING: - from typing_extensions import Self, Unpack + from typing_extensions import Self from discord.abc import Snowflake from discord._types import ClientT from .bot import BotBase from .context import Context - from .core import Command, _CommandDecoratorKwargs - - class _CogKwargs(TypedDict, total=False): - name: str - group_name: Union[str, app_commands.locale_str] - description: str - group_description: Union[str, app_commands.locale_str] - group_nsfw: bool - group_auto_locale_strings: bool - group_extras: Dict[Any, Any] - command_attrs: _CommandDecoratorKwargs + from .core import Command __all__ = ( @@ -182,7 +171,7 @@ async def bar(self, ctx): __cog_app_commands__: List[Union[app_commands.Group, app_commands.Command[Any, ..., Any]]] __cog_listeners__: List[Tuple[str, str]] - def __new__(cls, *args: Any, **kwargs: Unpack[_CogKwargs]) -> CogMeta: + def __new__(cls, *args: Any, **kwargs: Any) -> CogMeta: name, bases, attrs = args if any(issubclass(base, app_commands.Group) for base in bases): raise TypeError( diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index 9ec0dd484438..949539b61176 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -68,11 +68,11 @@ class _CommandDecoratorKwargs(TypedDict, total=False): enabled: bool - help: str - brief: str - usage: str + help: Optional[str] + brief: Optional[str] + usage: Optional[str] rest_is_raw: bool - aliases: List[str] + aliases: Union[List[str], Tuple[str, ...]] description: str hidden: bool checks: List[UserCheck[Context[Any]]] @@ -449,7 +449,7 @@ def __init__( self.brief: Optional[str] = kwargs.get('brief') self.usage: Optional[str] = kwargs.get('usage') self.rest_is_raw: bool = kwargs.get('rest_is_raw', False) - self.aliases: Union[List[str], Tuple[str]] = kwargs.get('aliases', []) + self.aliases: Union[List[str], Tuple[str, ...]] = kwargs.get('aliases', []) self.extras: Dict[Any, Any] = kwargs.get('extras', {}) if not isinstance(self.aliases, (list, tuple)): diff --git a/discord/ext/commands/help.py b/discord/ext/commands/help.py index dabbd9ef9c8d..10648b4cc1cc 100644 --- a/discord/ext/commands/help.py +++ b/discord/ext/commands/help.py @@ -69,12 +69,12 @@ class _HelpCommandOptions(TypedDict, total=False): show_hidden: bool - verify_checks: bool + verify_checks: Optional[bool] command_attrs: _CommandKwargs class _BaseHelpCommandOptions(_HelpCommandOptions, total=False): sort_commands: bool - dm_help: bool + dm_help: Optional[bool] dm_help_threshold: int no_category: str paginator: Paginator @@ -394,7 +394,7 @@ def __new__(cls, *args: Any, **kwargs: Any) -> Self: def __init__(self, **options: Unpack[_HelpCommandOptions]) -> None: self.show_hidden: bool = options.pop('show_hidden', False) - self.verify_checks: bool = options.pop('verify_checks', True) + self.verify_checks: Optional[bool] = options.pop('verify_checks', True) self.command_attrs = attrs = options.pop('command_attrs', {}) attrs.setdefault('name', 'help') attrs.setdefault('help', 'Shows this message') @@ -1070,7 +1070,7 @@ def __init__(self, **options: Unpack[_DefaultHelpCommandOptions]) -> None: self.width: int = options.pop('width', 80) self.indent: int = options.pop('indent', 2) self.sort_commands: bool = options.pop('sort_commands', True) - self.dm_help: bool = options.pop('dm_help', False) + self.dm_help: Optional[bool] = options.pop('dm_help', False) self.dm_help_threshold: int = options.pop('dm_help_threshold', 1000) self.arguments_heading: str = options.pop('arguments_heading', 'Arguments:') self.commands_heading: str = options.pop('commands_heading', 'Commands:') @@ -1364,7 +1364,7 @@ class MinimalHelpCommand(HelpCommand): def __init__(self, **options: Unpack[_MinimalHelpCommandOptions]) -> None: self.sort_commands: bool = options.pop('sort_commands', True) self.commands_heading: str = options.pop('commands_heading', 'Commands') - self.dm_help: bool = options.pop('dm_help', False) + self.dm_help: Optional[bool] = options.pop('dm_help', False) self.dm_help_threshold: int = options.pop('dm_help_threshold', 1000) self.aliases_heading: str = options.pop('aliases_heading', 'Aliases:') self.no_category: str = options.pop('no_category', 'No Category') diff --git a/discord/ext/commands/hybrid.py b/discord/ext/commands/hybrid.py index 70d18f5d14cd..6687104cbff4 100644 --- a/discord/ext/commands/hybrid.py +++ b/discord/ext/commands/hybrid.py @@ -67,10 +67,12 @@ class _HybridGroupKwargs(_HybridCommandDecoratorKwargs, total=False): default_permissions: bool nsfw: bool description: str + case_insensitive: bool class _HybridGroupDecoratorKwargs(_HybridGroupKwargs, total=False): description: Union[str, app_commands.locale_str] - fallback: Union[str, app_commands.locale_str] + fallback: Optional[str] + fallback_locale: Optional[app_commands.locale_str] __all__ = ( From b837d94d1a4abf5288fc86aed36fd1c1cb051e6d Mon Sep 17 00:00:00 2001 From: Martin Date: Sun, 28 Sep 2025 18:15:11 +0200 Subject: [PATCH 132/138] Add silent parameter to ForumChannel.create_thread --- discord/channel.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/discord/channel.py b/discord/channel.py index 3bfaeba0f9d7..17a1c0fb2d67 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -2879,6 +2879,7 @@ async def create_thread( applied_tags: Sequence[ForumTag] = ..., view: LayoutView, suppress_embeds: bool = ..., + silent: bool = ..., reason: Optional[str] = ..., ) -> ThreadWithMessage: ... @@ -2901,6 +2902,7 @@ async def create_thread( applied_tags: Sequence[ForumTag] = ..., view: View = ..., suppress_embeds: bool = ..., + silent: bool = ..., reason: Optional[str] = ..., ) -> ThreadWithMessage: ... @@ -2922,6 +2924,7 @@ async def create_thread( applied_tags: Sequence[ForumTag] = MISSING, view: BaseView = MISSING, suppress_embeds: bool = False, + silent: bool = False, reason: Optional[str] = None, ) -> ThreadWithMessage: """|coro| @@ -2976,6 +2979,11 @@ async def create_thread( A list of stickers to upload. Must be a maximum of 3. suppress_embeds: :class:`bool` Whether to suppress embeds for the message. This sends the message without any embeds if set to ``True``. + silent: :class:`bool` + Whether to suppress push and desktop notifications for the message. This will increment the mention counter + in the UI, but will not actually send a notification. + + .. versionadded:: 2.7 reason: :class:`str` The reason for creating a new thread. Shows up on the audit log. @@ -3008,8 +3016,10 @@ async def create_thread( if view and not hasattr(view, '__discord_ui_view__'): raise TypeError(f'view parameter must be View not {view.__class__.__name__}') - if suppress_embeds: - flags = MessageFlags._from_value(4) + if suppress_embeds or silent: + flags = MessageFlags._from_value(0) + flags.suppress_embeds = suppress_embeds + flags.suppress_notifications = silent else: flags = MISSING From a2a00ae8afb8aaa17f642cfa8d8afcb534254053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20N=C3=B8rgaard?= Date: Sun, 28 Sep 2025 17:32:36 +0100 Subject: [PATCH 133/138] Fix unordered list rendering for several AuditLogAction entries --- docs/api.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/api.rst b/docs/api.rst index ab8f4f5caaad..1a8365d819b1 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -2824,6 +2824,7 @@ of :class:`enum.Enum`. which was created. Possible attributes for :class:`AuditLogDiff`: + - :attr:`~AuditLogDiff.name` - :attr:`~AuditLogDiff.channel` - :attr:`~AuditLogDiff.description` @@ -2843,6 +2844,7 @@ of :class:`enum.Enum`. which was updated. Possible attributes for :class:`AuditLogDiff`: + - :attr:`~AuditLogDiff.name` - :attr:`~AuditLogDiff.channel` - :attr:`~AuditLogDiff.description` @@ -2862,6 +2864,7 @@ of :class:`enum.Enum`. which was deleted. Possible attributes for :class:`AuditLogDiff`: + - :attr:`~AuditLogDiff.name` - :attr:`~AuditLogDiff.channel` - :attr:`~AuditLogDiff.description` From 6ad55415736bfd0b8bb7562c0aaa0fdac1a56e6c Mon Sep 17 00:00:00 2001 From: Levi Pesin <35454228+LeviPesin@users.noreply.github.com> Date: Mon, 29 Sep 2025 02:02:52 +0930 Subject: [PATCH 134/138] Add missing InviteType and ReactionType exports --- discord/enums.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/discord/enums.py b/discord/enums.py index 172f736a9adc..0f1d0269565b 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -75,6 +75,8 @@ 'EntitlementType', 'EntitlementOwnerType', 'PollLayoutType', + 'InviteType', + 'ReactionType', 'VoiceChannelEffectAnimationType', 'SubscriptionStatus', 'MessageReferenceType', From 46300dfc62f9e6d06654780e07f4e46411006244 Mon Sep 17 00:00:00 2001 From: Sacul Date: Mon, 29 Sep 2025 00:36:24 +0800 Subject: [PATCH 135/138] Add missing reason kwarg in delete_invite --- discord/client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/discord/client.py b/discord/client.py index 63c86f352485..0b6e4fa4bd81 100644 --- a/discord/client.py +++ b/discord/client.py @@ -2511,7 +2511,7 @@ async def fetch_invite( ) return Invite.from_incomplete(state=self._connection, data=data) - async def delete_invite(self, invite: Union[Invite, str], /) -> Invite: + async def delete_invite(self, invite: Union[Invite, str], /, *, reason: Optional[str]) -> Invite: """|coro| Revokes an :class:`.Invite`, URL, or ID to an invite. @@ -2527,6 +2527,8 @@ async def delete_invite(self, invite: Union[Invite, str], /) -> Invite: ---------- invite: Union[:class:`.Invite`, :class:`str`] The invite to revoke. + reason: Optional[:class:`str`] + The reason for deleting the invite. Shows up on the audit log. Raises ------- @@ -2539,7 +2541,7 @@ async def delete_invite(self, invite: Union[Invite, str], /) -> Invite: """ resolved = utils.resolve_invite(invite) - data = await self.http.delete_invite(resolved.code) + data = await self.http.delete_invite(resolved.code, reason=reason) return Invite.from_incomplete(state=self._connection, data=data) # Miscellaneous stuff From c050ed02c3431895f537486bdfe2d0d55f103cd5 Mon Sep 17 00:00:00 2001 From: Sacul Date: Mon, 29 Sep 2025 00:54:54 +0800 Subject: [PATCH 136/138] Support new fields in Modify Current Member --- discord/http.py | 13 +++++------ discord/member.py | 55 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/discord/http.py b/discord/http.py index 7b82fddb6e4b..acf3835f646e 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1136,18 +1136,15 @@ def guild_voice_state( def edit_profile(self, payload: Dict[str, Any]) -> Response[user.User]: return self.request(Route('PATCH', '/users/@me'), json=payload) - def change_my_nickname( + def edit_my_member( self, guild_id: Snowflake, - nickname: str, *, reason: Optional[str] = None, - ) -> Response[member.Nickname]: - r = Route('PATCH', '/guilds/{guild_id}/members/@me/nick', guild_id=guild_id) - payload = { - 'nick': nickname, - } - return self.request(r, json=payload, reason=reason) + **fields: Any, + ) -> Response[member.MemberWithUser]: + r = Route('PATCH', '/guilds/{guild_id}/members/@me', guild_id=guild_id) + return self.request(r, json=fields, reason=reason) def change_nickname( self, diff --git a/discord/member.py b/discord/member.py index fd2cf7edba15..69ebada01508 100644 --- a/discord/member.py +++ b/discord/member.py @@ -815,12 +815,22 @@ async def edit( voice_channel: Optional[VocalGuildChannel] = MISSING, timed_out_until: Optional[datetime.datetime] = MISSING, bypass_verification: bool = MISSING, + avatar: Optional[bytes] = MISSING, + banner: Optional[bytes] = MISSING, + bio: Optional[str] = MISSING, reason: Optional[str] = None, ) -> Optional[Member]: """|coro| Edits the member's data. + .. note:: + + To upload an avatar or banner, a :term:`py:bytes-like object` must be passed in that + represents the image being uploaded. If this is done through a file + then the file must be opened via ``open('some_filename', 'rb')`` and + the :term:`py:bytes-like object` is given through the use of ``fp.read()``. + Depending on the parameter passed, this requires different permissions listed below: +---------------------+---------------------------------------+ @@ -876,6 +886,20 @@ async def edit( Indicates if the member should be allowed to bypass the guild verification requirements. .. versionadded:: 2.2 + avatar: Optional[:class:`bytes`] + A :term:`py:bytes-like object` representing the image to upload. Could be ``None`` to denote no avatar. + Only image formats supported for uploading are JPEG, PNG, GIF, and WEBP. + This can only be set when editing the bot's own member. + .. versionadded:: 2.7 + banner: Optional[:class:`bytes`] + A :term:`py:bytes-like object` representing the image to upload. Could be ``None`` to denote no banner. + Only image formats supported for uploading are JPEG, PNG, GIF and WEBP.. + This can only be set when editing the bot's own member. + .. versionadded:: 2.7 + bio: Optional[:class:`str`] + The new bio for the member. Use ``None`` to remove the bio. + This can only be set when editing the bot's own member. + .. versionadded:: 2.7 reason: Optional[:class:`str`] The reason for editing this member. Shows up on the audit log. @@ -888,6 +912,9 @@ async def edit( The operation failed. TypeError The datetime object passed to ``timed_out_until`` was not timezone-aware. + ValueError + You tried to edit the bio, avatar or banner of a member that is not the bot's own member. + Or the wrong image format passed for ``avatar`` or ``banner``. Returns -------- @@ -899,14 +926,33 @@ async def edit( guild_id = self.guild.id me = self._state.self_id == self.id payload: Dict[str, Any] = {} + self_payload: Dict[str, Any] = {} if nick is not MISSING: nick = nick or '' if me: - await http.change_my_nickname(guild_id, nick, reason=reason) + self_payload['nick'] = nick else: payload['nick'] = nick + if avatar is not MISSING: + if avatar is None: + self_payload['avatar'] = None + else: + self_payload['avatar'] = utils._bytes_to_base64_data(avatar) + + if banner is not MISSING: + if banner is None: + self_payload['banner'] = None + else: + self_payload['banner'] = utils._bytes_to_base64_data(banner) + + if bio is not MISSING: + self_payload['bio'] = bio or '' + + if not me and self_payload: + raise ValueError("Editing the bio, avatar or banner is only for the bot's own member.") + if deafen is not MISSING: payload['deaf'] = deafen @@ -954,7 +1000,12 @@ async def edit( if payload: data = await http.edit_member(guild_id, self.id, reason=reason, **payload) - return Member(data=data, guild=self.guild, state=self._state) + elif self_payload: + data = await http.edit_my_member(guild_id, reason=reason, **self_payload) + else: + return None + + return Member(data=data, guild=self.guild, state=self._state) async def request_to_speak(self) -> None: """|coro| From 78ff16621a5552fe30c562c5c662c446777e668b Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sun, 28 Sep 2025 13:05:23 -0400 Subject: [PATCH 137/138] Fix certain component IDs not being able to be settable afterwards Fix #10305 --- discord/ui/file.py | 10 +++++++++- discord/ui/separator.py | 9 +++++++++ discord/ui/text_input.py | 10 +++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/discord/ui/file.py b/discord/ui/file.py index 92b927ac0831..acebc5ace982 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -100,7 +100,15 @@ def __init__( spoiler=bool(spoiler), id=id, ) - self.id = id + + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this file component.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value def _is_v2(self): return True diff --git a/discord/ui/separator.py b/discord/ui/separator.py index e6dc61f0002f..9f34341daaa1 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -83,6 +83,15 @@ def __init__( def _is_v2(self): return True + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this separator.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value + @property def visible(self) -> bool: """:class:`bool`: Whether this separator is visible. diff --git a/discord/ui/text_input.py b/discord/ui/text_input.py index de0c8e079364..e93a710ca383 100644 --- a/discord/ui/text_input.py +++ b/discord/ui/text_input.py @@ -144,11 +144,19 @@ def __init__( id=id, ) self.row = row - self.id = id def __str__(self) -> str: return self.value + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this text input.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value + @property def custom_id(self) -> str: """:class:`str`: The ID of the text input that gets received during an interaction.""" From 830858cde8c67fcb4e2a5884be389a4107369e5b Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sun, 28 Sep 2025 13:17:54 -0400 Subject: [PATCH 138/138] Add MessageType.is_deletable to fix Messageable.purge failing Fix #10319 --- discord/abc.py | 3 +++ discord/enums.py | 10 ++++++++++ docs/api.rst | 9 +++++++++ 3 files changed, 22 insertions(+) diff --git a/discord/abc.py b/discord/abc.py index 535c70abaad4..5f06fb1ce5fa 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -195,6 +195,9 @@ async def _purge_helper( count = 0 await asyncio.sleep(1) + if not message.type.is_deletable(): + continue + if not check(message): continue diff --git a/discord/enums.py b/discord/enums.py index 0f1d0269565b..d691e6b48fa6 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -279,6 +279,16 @@ class MessageType(Enum): poll_result = 46 emoji_added = 63 + def is_deletable(self) -> bool: + return self not in { + MessageType.recipient_add, + MessageType.recipient_remove, + MessageType.call, + MessageType.channel_name_change, + MessageType.channel_icon_change, + MessageType.thread_starter_message, + } + class SpeakingState(Enum): none = 0 diff --git a/docs/api.rst b/docs/api.rst index 1a8365d819b1..495636b12959 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1732,6 +1732,15 @@ of :class:`enum.Enum`. Checks if two messages are not equal. + .. method:: is_deletable() + + Checks if the message type is deletable, as some system messages cannot be deleted. + + .. versionadded:: 2.7 + + :return: A boolean denoting if the message type is deletable. + :rtype: :class:`bool` + .. attribute:: default The default message type. This is the same as regular messages.