diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 79b7ac8ec..2cacf8f2b 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 3e6e1c0e6..f4d7af42e 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -13,7 +13,7 @@ __title__ = 'discord' __author__ = 'Rapptz' __license__ = 'MIT' __copyright__ = 'Copyright 2015-present Rapptz' -__version__ = '2.6.0a' +__version__ = '2.7.0a' __path__ = __import__('pkgutil').extend_path(__path__, __name__) @@ -74,17 +74,18 @@ from .soundboard import * from .subscription import * from .presences import * from .primary_guild import * +from .onboarding import * class VersionInfo(NamedTuple): major: int minor: int micro: int - releaselevel: Literal["alpha", "beta", "candidate", "final"] + releaselevel: Literal['alpha', 'beta', 'candidate', 'final'] serial: int -version_info: VersionInfo = VersionInfo(major=2, minor=6, micro=0, releaselevel='alpha', serial=0) +version_info: VersionInfo = VersionInfo(major=2, minor=7, micro=0, releaselevel='alpha', serial=0) logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/discord/__main__.py b/discord/__main__.py index f8556fcdc..455c5e8ed 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 @@ _cog_extras = ''' # 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 4fa98b59e..9b7d63e01 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -34,8 +34,10 @@ from typing import ( AsyncIterator, Callable, Dict, + Generator, Iterable, List, + Literal, Optional, TYPE_CHECKING, Protocol, @@ -61,6 +63,7 @@ from .voice_client import VoiceClient, VoiceProtocol from .sticker import GuildSticker, StickerItem from . import utils from .flags import InviteFlags +import warnings __all__ = ( 'Snowflake', @@ -74,7 +77,7 @@ __all__ = ( 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 @@ -96,7 +99,7 @@ if TYPE_CHECKING: ) 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, @@ -109,10 +112,16 @@ if TYPE_CHECKING: from .types.snowflake import ( SnowflakeList, ) + from .permissions import _PermissionOverwriteKwargs 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 + pinned: Literal[True] + MISSING = utils.MISSING @@ -125,6 +134,26 @@ class _Undefined: _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: @@ -394,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 @@ -765,7 +793,6 @@ class GuildChannel: 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: @@ -878,8 +905,7 @@ class GuildChannel: *, overwrite: Optional[Union[PermissionOverwrite, _Undefined]] = ..., reason: Optional[str] = ..., - ) -> None: - ... + ) -> None: ... @overload async def set_permissions( @@ -887,9 +913,8 @@ class GuildChannel: target: Union[Member, Role], *, reason: Optional[str] = ..., - **permissions: Optional[bool], - ) -> None: - ... + **permissions: Unpack[_PermissionOverwriteKwargs], + ) -> None: ... async def set_permissions( self, @@ -897,7 +922,7 @@ class GuildChannel: *, overwrite: Any = _undefined, reason: Optional[str] = None, - **permissions: Optional[bool], + **permissions: Unpack[_PermissionOverwriteKwargs], ) -> None: r"""|coro| @@ -1080,8 +1105,7 @@ class GuildChannel: category: Optional[Snowflake] = MISSING, sync_permissions: bool = MISSING, reason: Optional[str] = MISSING, - ) -> None: - ... + ) -> None: ... @overload async def move( @@ -1092,8 +1116,7 @@ class GuildChannel: category: Optional[Snowflake] = MISSING, sync_permissions: bool = MISSING, reason: str = MISSING, - ) -> None: - ... + ) -> None: ... @overload async def move( @@ -1104,8 +1127,7 @@ class GuildChannel: category: Optional[Snowflake] = MISSING, sync_permissions: bool = MISSING, reason: str = MISSING, - ) -> None: - ... + ) -> None: ... @overload async def move( @@ -1116,8 +1138,7 @@ class GuildChannel: category: Optional[Snowflake] = MISSING, sync_permissions: bool = MISSING, reason: str = MISSING, - ) -> None: - ... + ) -> None: ... async def move(self, **kwargs: Any) -> None: """|coro| @@ -1386,6 +1407,36 @@ 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, @@ -1404,8 +1455,7 @@ class Messageable: suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def send( @@ -1425,8 +1475,7 @@ class Messageable: suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def send( @@ -1446,8 +1495,7 @@ class Messageable: suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def send( @@ -1467,8 +1515,7 @@ class Messageable: suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... async def send( self, @@ -1485,7 +1532,7 @@ class Messageable: 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 +1605,7 @@ class Messageable: 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 @@ -1665,7 +1712,7 @@ class Messageable: 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: @@ -1731,17 +1778,119 @@ class Messageable: 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 + ) - Retrieves all messages that are currently pinned in the channel. + 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. + + 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 @@ -1749,16 +1898,12 @@ class Messageable: ~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, @@ -1879,7 +2024,7 @@ class Messageable: 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 0fc0faa64..d15da49a5 100644 --- a/discord/activity.py +++ b/discord/activity.py @@ -28,7 +28,7 @@ import datetime 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 @@ class Activity(BaseActivity): 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', 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] = ( + 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 @@ class Activity(BaseActivity): 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 @@ -829,13 +861,11 @@ ActivityTypes = Union[Activity, Game, CustomActivity, Streaming, Spotify] @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 5c17b951c..0ee65dea6 100644 --- a/discord/app_commands/checks.py +++ b/discord/app_commands/checks.py @@ -55,8 +55,9 @@ from ..utils import get as utils_get, MISSING, maybe_coroutine 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 has_any_role(*items: Union[int, str]) -> Callable[[T], T]: 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. @@ -326,7 +327,7 @@ def has_permissions(**perms: bool) -> Callable[[T], T]: 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 @@ -341,7 +342,7 @@ def has_permissions(**perms: bool) -> Callable[[T], T]: 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`. @@ -353,7 +354,7 @@ def bot_has_permissions(**perms: bool) -> 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 @@ -370,7 +371,6 @@ def bot_has_permissions(**perms: bool) -> Callable[[T], T]: 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 1496a82bb..a23682f8b 100644 --- a/discord/app_commands/commands.py +++ b/discord/app_commands/commands.py @@ -61,7 +61,7 @@ from ..permissions import Permissions 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 @@ if TYPE_CHECKING: # 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]] @@ -218,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 @@ -1490,9 +1491,9 @@ class Group: __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, @@ -2483,13 +2484,11 @@ def check(predicate: Check) -> Callable[[T], T]: @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]]: @@ -2524,7 +2523,10 @@ def guild_only(func: Optional[T] = None) -> Union[T, Callable[[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 = True # Enable guild context + allowed_contexts.private_channel = False # Disable private channel context + allowed_contexts.dm_channel = False # Disable DM context return f @@ -2537,13 +2539,11 @@ def guild_only(func: Optional[T] = None) -> Union[T, Callable[[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]]: @@ -2578,7 +2578,10 @@ def private_channel_only(func: Optional[T] = None) -> Union[T, Callable[[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 @@ -2591,13 +2594,11 @@ def private_channel_only(func: Optional[T] = None) -> Union[T, Callable[[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]]: @@ -2630,7 +2631,11 @@ def dm_only(func: Optional[T] = None) -> Union[T, Callable[[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 @@ -2686,13 +2691,11 @@ def allowed_contexts(guilds: bool = MISSING, dms: bool = MISSING, private_channe @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]]: @@ -2724,6 +2727,7 @@ def guild_install(func: Optional[T] = None) -> Union[T, Callable[[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 @@ -2736,13 +2740,11 @@ def guild_install(func: Optional[T] = None) -> Union[T, Callable[[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]]: @@ -2774,6 +2776,7 @@ def user_install(func: Optional[T] = None) -> Union[T, Callable[[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 @@ -2828,7 +2831,7 @@ def allowed_installs( 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/app_commands/models.py b/discord/app_commands/models.py index 5851e7d8c..b51339c26 100644 --- a/discord/app_commands/models.py +++ b/discord/app_commands/models.py @@ -37,6 +37,7 @@ from ..enums import ( 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 @@ if TYPE_CHECKING: 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 @@ class AppCommandChannel(Hashable): """: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,34 @@ 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 + 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 the application command in that thread. @@ -792,6 +829,14 @@ class AppCommandThread(Hashable): 'archive_timestamp', 'locked', 'invitable', + 'owner_id', + 'message_count', + 'member_count', + 'slowmode_delay', + 'last_message_id', + 'total_message_sent', + '_applied_tags', + '_flags', '_created_at', '_state', ) @@ -810,6 +855,14 @@ class AppCommandThread(Hashable): 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.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']) def __str__(self) -> str: @@ -833,15 +886,58 @@ class AppCommandThread(Hashable): 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.""" - return self.guild.get_channel(self.parent_id) # type: ignore + 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 and 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 and self.guild.get_member(self.owner_id) @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/app_commands/namespace.py b/discord/app_commands/namespace.py index 3fa81712c..0cac8cb24 100644 --- a/discord/app_commands/namespace.py +++ b/discord/app_commands/namespace.py @@ -181,7 +181,7 @@ class Namespace: 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 @@ class Namespace: ) 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 @@ class Namespace: } ) - 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 4b6e01d4b..36b1b923c 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 3099071c0..aa446a01f 100644 --- a/discord/app_commands/tree.py +++ b/discord/app_commands/tree.py @@ -419,8 +419,7 @@ class CommandTree(Generic[ClientT]): *, guild: Optional[Snowflake] = ..., type: Literal[AppCommandType.message, AppCommandType.user], - ) -> Optional[ContextMenu]: - ... + ) -> Optional[ContextMenu]: ... @overload def remove_command( @@ -430,8 +429,7 @@ class CommandTree(Generic[ClientT]): *, 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 @@ class CommandTree(Generic[ClientT]): *, 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 @@ class CommandTree(Generic[ClientT]): *, guild: Optional[Snowflake] = ..., type: Literal[AppCommandType.message, AppCommandType.user], - ) -> Optional[ContextMenu]: - ... + ) -> Optional[ContextMenu]: ... @overload def get_command( @@ -550,8 +546,7 @@ class CommandTree(Generic[ClientT]): *, 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 @@ class CommandTree(Generic[ClientT]): *, 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 @@ class CommandTree(Generic[ClientT]): *, guild: Optional[Snowflake] = ..., type: Literal[AppCommandType.message, AppCommandType.user], - ) -> List[ContextMenu]: - ... + ) -> List[ContextMenu]: ... @overload def get_commands( @@ -622,8 +615,7 @@ class CommandTree(Generic[ClientT]): *, 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 @@ class CommandTree(Generic[ClientT]): *, 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 @@ class CommandTree(Generic[ClientT]): *, 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 @@ class CommandTree(Generic[ClientT]): *, 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 @@ class CommandTree(Generic[ClientT]): *, 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 @@ class CommandTree(Generic[ClientT]): *, 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 990c7c2fe..9dd70f7ef 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -406,7 +406,7 @@ class AppInfo: 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 cbf7dd4b2..a3ed53c6b 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -50,8 +50,8 @@ if TYPE_CHECKING: 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 @@ class Asset(AssetMixin): 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 @@ class Asset(AssetMixin): 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 2c0fd610f..c27a793c3 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -44,6 +44,7 @@ from .sticker import GuildSticker 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 @@ if TYPE_CHECKING: 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 _flag_transformer(cls: Type[F]) -> Callable[[AuditLogEntry, Union[int, str]] 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) @@ -292,14 +306,12 @@ class AuditLogDiff: 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: @@ -353,7 +365,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 @@ -761,7 +777,7 @@ class AuditLogEntry(Hashable): 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 +785,7 @@ class AuditLogEntry(Hashable): 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']) ), ) @@ -977,3 +993,6 @@ class AuditLogEntry(Hashable): 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/automod.py b/discord/automod.py index 61683c269..5441d9467 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 @@ class AutoModRuleAction: 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 cfb93ad23..f40142a9a 100644 --- a/discord/backoff.py +++ b/discord/backoff.py @@ -75,16 +75,13 @@ class ExponentialBackoff(Generic[T]): 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 a306707d6..3bfaeba0f 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -39,6 +39,7 @@ from typing import ( Sequence, Tuple, TypeVar, + TypedDict, Union, overload, ) @@ -85,7 +86,7 @@ __all__ = ( ) if TYPE_CHECKING: - from typing_extensions import Self + from typing_extensions import Self, Unpack from .types.threads import ThreadArchiveDuration from .role import Role @@ -100,7 +101,7 @@ if TYPE_CHECKING: 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, @@ -120,6 +121,44 @@ if TYPE_CHECKING: 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 @@ -168,7 +207,7 @@ class VoiceChannelSoundEffect(BaseSoundboardSound): 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]: @@ -234,7 +273,7 @@ class VoiceChannelEffect: ('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.""" @@ -418,12 +457,10 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): 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( @@ -441,8 +478,7 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): 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| @@ -1512,12 +1548,10 @@ class VoiceChannel(VocalGuildChannel): 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( @@ -1536,8 +1570,7 @@ class VoiceChannel(VocalGuildChannel): slowmode_delay: int = ..., status: Optional[str] = ..., reason: Optional[str] = ..., - ) -> VoiceChannel: - ... + ) -> VoiceChannel: ... async def edit(self, *, reason: Optional[str] = None, **options: Any) -> Optional[VoiceChannel]: """|coro| @@ -1878,12 +1911,10 @@ class StageChannel(VocalGuildChannel): 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( @@ -1901,8 +1932,7 @@ class StageChannel(VocalGuildChannel): 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| @@ -2068,12 +2098,10 @@ class CategoryChannel(discord.abc.GuildChannel, Hashable): 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( @@ -2084,8 +2112,7 @@ class CategoryChannel(discord.abc.GuildChannel, Hashable): nsfw: bool = ..., overwrites: Mapping[OverwriteKeyT, PermissionOverwrite] = ..., reason: Optional[str] = ..., - ) -> CategoryChannel: - ... + ) -> CategoryChannel: ... async def edit(self, *, reason: Optional[str] = None, **options: Any) -> Optional[CategoryChannel]: """|coro| @@ -2194,7 +2221,7 @@ class CategoryChannel(discord.abc.GuildChannel, Hashable): 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 +2233,7 @@ class CategoryChannel(discord.abc.GuildChannel, Hashable): """ 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 +2245,7 @@ class CategoryChannel(discord.abc.GuildChannel, Hashable): """ 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 +2259,7 @@ class CategoryChannel(discord.abc.GuildChannel, Hashable): """ 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. @@ -2619,12 +2646,10 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): ) @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( @@ -2647,8 +2672,7 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): 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| @@ -2841,6 +2865,45 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): 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 +2920,7 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): 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 +2970,7 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): 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 +3046,7 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): 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 4f16e6ff5..cfd8fb122 100644 --- a/discord/client.py +++ b/discord/client.py @@ -42,6 +42,7 @@ from typing import ( Tuple, Type, TypeVar, + TypedDict, Union, overload, ) @@ -72,7 +73,7 @@ from .object import Object 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 @@ -82,7 +83,7 @@ from .soundboard import SoundboardDefaultSound, SoundboardSound 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 @@ if TYPE_CHECKING: 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 @@ -315,7 +338,7 @@ class Client: 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() @@ -751,7 +774,7 @@ class Client: 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. @@ -1215,8 +1238,7 @@ class Client: *, check: Optional[Callable[[RawAppCommandPermissionsUpdateEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawAppCommandPermissionsUpdateEvent: - ... + ) -> RawAppCommandPermissionsUpdateEvent: ... @overload async def wait_for( @@ -1226,8 +1248,7 @@ class Client: *, 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 @@ -1239,8 +1260,7 @@ class Client: *, check: Optional[Callable[[AutoModRule], bool]] = ..., timeout: Optional[float] = ..., - ) -> AutoModRule: - ... + ) -> AutoModRule: ... @overload async def wait_for( @@ -1250,8 +1270,7 @@ class Client: *, check: Optional[Callable[[AutoModAction], bool]] = ..., timeout: Optional[float] = ..., - ) -> AutoModAction: - ... + ) -> AutoModAction: ... # Channels @@ -1263,8 +1282,7 @@ class Client: *, check: Optional[Callable[[GroupChannel, GroupChannel], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[GroupChannel, GroupChannel]: - ... + ) -> Tuple[GroupChannel, GroupChannel]: ... @overload async def wait_for( @@ -1274,8 +1292,7 @@ class Client: *, check: Optional[Callable[[PrivateChannel, datetime.datetime], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[PrivateChannel, datetime.datetime]: - ... + ) -> Tuple[PrivateChannel, datetime.datetime]: ... @overload async def wait_for( @@ -1285,8 +1302,7 @@ class Client: *, check: Optional[Callable[[GuildChannel], bool]] = ..., timeout: Optional[float] = ..., - ) -> GuildChannel: - ... + ) -> GuildChannel: ... @overload async def wait_for( @@ -1296,8 +1312,7 @@ class Client: *, check: Optional[Callable[[GuildChannel, GuildChannel], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[GuildChannel, GuildChannel]: - ... + ) -> Tuple[GuildChannel, GuildChannel]: ... @overload async def wait_for( @@ -1312,8 +1327,7 @@ class Client: ] ], timeout: Optional[float] = ..., - ) -> Tuple[Union[GuildChannel, Thread], Optional[datetime.datetime]]: - ... + ) -> Tuple[Union[GuildChannel, Thread], Optional[datetime.datetime]]: ... @overload async def wait_for( @@ -1323,8 +1337,7 @@ class Client: *, 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( @@ -1334,8 +1347,7 @@ class Client: *, check: Optional[Callable[[RawTypingEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawTypingEvent: - ... + ) -> RawTypingEvent: ... # Debug & Gateway events @@ -1347,8 +1359,7 @@ class Client: *, check: Optional[Callable[[], bool]] = ..., timeout: Optional[float] = ..., - ) -> None: - ... + ) -> None: ... @overload async def wait_for( @@ -1358,8 +1369,7 @@ class Client: *, check: Optional[Callable[[int], bool]] = ..., timeout: Optional[float] = ..., - ) -> int: - ... + ) -> int: ... @overload async def wait_for( @@ -1369,8 +1379,7 @@ class Client: *, check: Optional[Callable[[str], bool]] = ..., timeout: Optional[float] = ..., - ) -> str: - ... + ) -> str: ... @overload async def wait_for( @@ -1380,8 +1389,7 @@ class Client: *, check: Optional[Callable[[Union[str, bytes]], bool]] = ..., timeout: Optional[float] = ..., - ) -> Union[str, bytes]: - ... + ) -> Union[str, bytes]: ... # Entitlements @overload @@ -1392,8 +1400,7 @@ class Client: *, check: Optional[Callable[[Entitlement], bool]] = ..., timeout: Optional[float] = ..., - ) -> Entitlement: - ... + ) -> Entitlement: ... # Guilds @@ -1410,8 +1417,7 @@ class Client: *, check: Optional[Callable[[Guild], bool]] = ..., timeout: Optional[float] = ..., - ) -> Guild: - ... + ) -> Guild: ... @overload async def wait_for( @@ -1421,8 +1427,7 @@ class Client: *, check: Optional[Callable[[Guild, Guild], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Guild, Guild]: - ... + ) -> Tuple[Guild, Guild]: ... @overload async def wait_for( @@ -1432,8 +1437,7 @@ class Client: *, 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( @@ -1443,8 +1447,7 @@ class Client: *, 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( @@ -1454,8 +1457,7 @@ class Client: *, check: Optional[Callable[[Invite], bool]] = ..., timeout: Optional[float] = ..., - ) -> Invite: - ... + ) -> Invite: ... @overload async def wait_for( @@ -1465,8 +1467,7 @@ class Client: *, check: Optional[Callable[[AuditLogEntry], bool]] = ..., timeout: Optional[float] = ..., - ) -> AuditLogEntry: - ... + ) -> AuditLogEntry: ... # Integrations @@ -1478,8 +1479,7 @@ class Client: *, check: Optional[Callable[[Integration], bool]] = ..., timeout: Optional[float] = ..., - ) -> Integration: - ... + ) -> Integration: ... @overload async def wait_for( @@ -1489,8 +1489,7 @@ class Client: *, check: Optional[Callable[[Guild], bool]] = ..., timeout: Optional[float] = ..., - ) -> Guild: - ... + ) -> Guild: ... @overload async def wait_for( @@ -1500,8 +1499,7 @@ class Client: *, check: Optional[Callable[[GuildChannel], bool]] = ..., timeout: Optional[float] = ..., - ) -> GuildChannel: - ... + ) -> GuildChannel: ... @overload async def wait_for( @@ -1511,8 +1509,7 @@ class Client: *, check: Optional[Callable[[RawIntegrationDeleteEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawIntegrationDeleteEvent: - ... + ) -> RawIntegrationDeleteEvent: ... # Interactions @@ -1524,8 +1521,7 @@ class Client: *, check: Optional[Callable[[Interaction[Self]], bool]] = ..., timeout: Optional[float] = ..., - ) -> Interaction[Self]: - ... + ) -> Interaction[Self]: ... # Members @@ -1537,8 +1533,7 @@ class Client: *, check: Optional[Callable[[Member], bool]] = ..., timeout: Optional[float] = ..., - ) -> Member: - ... + ) -> Member: ... @overload async def wait_for( @@ -1548,8 +1543,7 @@ class Client: *, check: Optional[Callable[[RawMemberRemoveEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawMemberRemoveEvent: - ... + ) -> RawMemberRemoveEvent: ... @overload async def wait_for( @@ -1559,8 +1553,7 @@ class Client: *, check: Optional[Callable[[Member, Member], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Member, Member]: - ... + ) -> Tuple[Member, Member]: ... @overload async def wait_for( @@ -1570,8 +1563,7 @@ class Client: *, check: Optional[Callable[[User, User], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[User, User]: - ... + ) -> Tuple[User, User]: ... @overload async def wait_for( @@ -1581,8 +1573,7 @@ class Client: *, 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( @@ -1592,8 +1583,7 @@ class Client: *, check: Optional[Callable[[Guild, User], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Guild, User]: - ... + ) -> Tuple[Guild, User]: ... # Messages @@ -1605,8 +1595,7 @@ class Client: *, check: Optional[Callable[[Message], bool]] = ..., timeout: Optional[float] = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def wait_for( @@ -1616,8 +1605,7 @@ class Client: *, check: Optional[Callable[[Message, Message], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Message, Message]: - ... + ) -> Tuple[Message, Message]: ... @overload async def wait_for( @@ -1627,8 +1615,7 @@ class Client: *, check: Optional[Callable[[List[Message]], bool]] = ..., timeout: Optional[float] = ..., - ) -> List[Message]: - ... + ) -> List[Message]: ... @overload async def wait_for( @@ -1638,8 +1625,7 @@ class Client: *, check: Optional[Callable[[RawMessageUpdateEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawMessageUpdateEvent: - ... + ) -> RawMessageUpdateEvent: ... @overload async def wait_for( @@ -1649,8 +1635,7 @@ class Client: *, check: Optional[Callable[[RawMessageDeleteEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawMessageDeleteEvent: - ... + ) -> RawMessageDeleteEvent: ... @overload async def wait_for( @@ -1660,8 +1645,7 @@ class Client: *, check: Optional[Callable[[RawBulkMessageDeleteEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawBulkMessageDeleteEvent: - ... + ) -> RawBulkMessageDeleteEvent: ... # Reactions @@ -1673,8 +1657,7 @@ class Client: *, 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( @@ -1684,8 +1667,7 @@ class Client: *, check: Optional[Callable[[Message, List[Reaction]], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Message, List[Reaction]]: - ... + ) -> Tuple[Message, List[Reaction]]: ... @overload async def wait_for( @@ -1695,8 +1677,7 @@ class Client: *, check: Optional[Callable[[Reaction], bool]] = ..., timeout: Optional[float] = ..., - ) -> Reaction: - ... + ) -> Reaction: ... @overload async def wait_for( @@ -1706,8 +1687,7 @@ class Client: *, check: Optional[Callable[[RawReactionActionEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawReactionActionEvent: - ... + ) -> RawReactionActionEvent: ... @overload async def wait_for( @@ -1717,8 +1697,7 @@ class Client: *, check: Optional[Callable[[RawReactionClearEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawReactionClearEvent: - ... + ) -> RawReactionClearEvent: ... @overload async def wait_for( @@ -1728,8 +1707,7 @@ class Client: *, check: Optional[Callable[[RawReactionClearEmojiEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawReactionClearEmojiEvent: - ... + ) -> RawReactionClearEmojiEvent: ... # Roles @@ -1741,8 +1719,7 @@ class Client: *, check: Optional[Callable[[Role], bool]] = ..., timeout: Optional[float] = ..., - ) -> Role: - ... + ) -> Role: ... @overload async def wait_for( @@ -1752,8 +1729,7 @@ class Client: *, check: Optional[Callable[[Role, Role], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Role, Role]: - ... + ) -> Tuple[Role, Role]: ... # Scheduled Events @@ -1765,8 +1741,7 @@ class Client: *, check: Optional[Callable[[ScheduledEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> ScheduledEvent: - ... + ) -> ScheduledEvent: ... @overload async def wait_for( @@ -1776,8 +1751,7 @@ class Client: *, check: Optional[Callable[[ScheduledEvent, User], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[ScheduledEvent, User]: - ... + ) -> Tuple[ScheduledEvent, User]: ... # Stages @@ -1789,8 +1763,7 @@ class Client: *, check: Optional[Callable[[StageInstance], bool]] = ..., timeout: Optional[float] = ..., - ) -> StageInstance: - ... + ) -> StageInstance: ... @overload async def wait_for( @@ -1800,8 +1773,7 @@ class Client: *, check: Optional[Callable[[StageInstance, StageInstance], bool]] = ..., timeout: Optional[float] = ..., - ) -> Coroutine[Any, Any, Tuple[StageInstance, StageInstance]]: - ... + ) -> Coroutine[Any, Any, Tuple[StageInstance, StageInstance]]: ... # Subscriptions @overload @@ -1812,8 +1784,7 @@ class Client: *, check: Optional[Callable[[Subscription], bool]] = ..., timeout: Optional[float] = ..., - ) -> Subscription: - ... + ) -> Subscription: ... # Threads @overload @@ -1824,8 +1795,7 @@ class Client: *, check: Optional[Callable[[Thread], bool]] = ..., timeout: Optional[float] = ..., - ) -> Thread: - ... + ) -> Thread: ... @overload async def wait_for( @@ -1835,8 +1805,7 @@ class Client: *, check: Optional[Callable[[Thread, Thread], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Thread, Thread]: - ... + ) -> Tuple[Thread, Thread]: ... @overload async def wait_for( @@ -1846,8 +1815,7 @@ class Client: *, check: Optional[Callable[[RawThreadUpdateEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawThreadUpdateEvent: - ... + ) -> RawThreadUpdateEvent: ... @overload async def wait_for( @@ -1857,8 +1825,7 @@ class Client: *, check: Optional[Callable[[RawThreadDeleteEvent], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawThreadDeleteEvent: - ... + ) -> RawThreadDeleteEvent: ... @overload async def wait_for( @@ -1868,8 +1835,7 @@ class Client: *, check: Optional[Callable[[ThreadMember], bool]] = ..., timeout: Optional[float] = ..., - ) -> ThreadMember: - ... + ) -> ThreadMember: ... @overload async def wait_for( @@ -1879,8 +1845,7 @@ class Client: *, check: Optional[Callable[[RawThreadMembersUpdate], bool]] = ..., timeout: Optional[float] = ..., - ) -> RawThreadMembersUpdate: - ... + ) -> RawThreadMembersUpdate: ... # Voice @@ -1892,8 +1857,7 @@ class Client: *, check: Optional[Callable[[Member, VoiceState, VoiceState], bool]] = ..., timeout: Optional[float] = ..., - ) -> Tuple[Member, VoiceState, VoiceState]: - ... + ) -> Tuple[Member, VoiceState, VoiceState]: ... # Polls @@ -1905,8 +1869,7 @@ class Client: *, 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( @@ -1916,32 +1879,29 @@ class Client: *, 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( @@ -1951,8 +1911,7 @@ class Client: *, check: Optional[Callable[..., bool]] = ..., timeout: Optional[float] = ..., - ) -> Any: - ... + ) -> Any: ... def wait_for( self, @@ -2510,6 +2469,9 @@ class Client: :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 +2507,6 @@ class Client: 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) @@ -3154,7 +3115,7 @@ class Client: 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 @@ -3164,7 +3125,7 @@ class Client: 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 @@ -3180,7 +3141,7 @@ class Client: 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(): @@ -3192,8 +3153,8 @@ class Client: 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 b62ab6bf9..0986680fc 100644 --- a/discord/components.py +++ b/discord/components.py @@ -24,9 +24,31 @@ DEALINGS IN THE SOFTWARE. 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 .file import File +from .utils import get_slots, MISSING, _get_as_snowflake from .partial_emoji import PartialEmoji, _EmojiTag if TYPE_CHECKING: @@ -39,13 +61,36 @@ if TYPE_CHECKING: 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, + LabelComponent as LabelComponentPayload, ) + 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 +101,36 @@ __all__ = ( 'SelectOption', 'TextInput', 'SelectDefaultValue', + 'SectionComponent', + 'ThumbnailComponent', + 'UnfurledMediaItem', + 'MediaGalleryItem', + 'MediaGalleryComponent', + 'FileComponent', + 'SectionComponent', + 'Container', + 'TextDisplay', + 'SeparatorComponent', + 'LabelComponent', ) 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 +179,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 +205,13 @@ class ActionRow(Component): 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 +245,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 +259,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 +294,9 @@ class Button(Component): '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 +348,14 @@ 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 + required: :class:`bool` + Whether the select is required. Only applicable within modals. + + .. versionadded:: 2.6 """ __slots__: Tuple[str, ...] = ( @@ -280,6 +368,8 @@ class SelectMenu(Component): 'disabled', 'channel_types', 'default_values', + 'required', + 'id', ) __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ @@ -290,12 +380,14 @@ class SelectMenu(Component): 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', [])] 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 +397,8 @@ class SelectMenu(Component): '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 +406,7 @@ class SelectMenu(Component): 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 @@ -459,7 +553,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. @@ -473,6 +567,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,19 +582,21 @@ class TextInput(Component): 'required', 'min_length', 'max_length', + 'id', ) __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ 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') 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 +612,9 @@ class TextInput(Component): 'required': self.required, } + if self.id is not None: + payload['id'] = self.id + if self.placeholder: payload['placeholder'] = self.placeholder @@ -645,17 +748,642 @@ class SelectDefaultValue: ) -@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 -@overload -def _component_factory(data: ComponentPayload) -> Optional[Union[ActionRow, ActionRowChildComponentType]]: - ... + +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:`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. + 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', + ) + + def __init__( + self, + media: Union[str, File, UnfurledMediaItem], + *, + description: Optional[str] = MISSING, + spoiler: bool = MISSING, + ) -> None: + self.media = media + + 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: + 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, 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__}') + + @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 + + +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) -> 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 +1391,20 @@ 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) + elif data['type'] == 18: + return LabelComponent(data, state) diff --git a/discord/embeds.py b/discord/embeds.py index f55c7cac1..b1c98e66b 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -189,7 +189,6 @@ class Embed: 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 @@ class Embed: 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 5ee07044c..4fe07ffce 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -78,6 +78,11 @@ __all__ = ( 'VoiceChannelEffectAnimationType', 'SubscriptionStatus', 'MessageReferenceType', + 'StatusDisplayType', + 'OnboardingPromptType', + 'OnboardingMode', + 'SeparatorSpacing', + 'MediaItemLoadingState', ) @@ -171,7 +176,7 @@ class EnumMeta(type): 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] @@ -401,6 +406,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 @@ -467,6 +479,13 @@ class AuditLogAction(Enum): 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] @@ -512,6 +531,12 @@ class AuditLogAction(Enum): 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): @@ -645,6 +670,14 @@ 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 + label = 18 def __int__(self) -> int: return self.value @@ -772,13 +805,6 @@ class Locale(Enum): @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) @@ -914,6 +940,34 @@ class SubscriptionStatus(Enum): inactive = 2 +class StatusDisplayType(Enum): + name = 0 # pyright: ignore[reportAssignmentType] + state = 1 + details = 2 + + +class OnboardingPromptType(Enum): + multiple_choice = 0 + dropdown = 1 + + +class OnboardingMode(Enum): + default = 0 + 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/errors.py b/discord/errors.py index a40842578..c07a7ed15 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -261,7 +261,7 @@ class PrivilegedIntentsRequired(ClientException): 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 1331c9f3b..d7801939c 100644 --- a/discord/ext/commands/_types.py +++ b/discord/ext/commands/_types.py @@ -22,7 +22,6 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ - from typing import Any, Awaitable, Callable, Coroutine, TYPE_CHECKING, Protocol, TypeVar, Union, Tuple, Optional @@ -49,9 +48,9 @@ MaybeCoro = Union[T, Coro[T]] 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 @@ ContextT_co = TypeVar('ContextT_co', bound='Context[Any]', covariant=True) 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 8ce872f1a..3a916d69e 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -64,7 +64,7 @@ from .cog import Cog 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,23 @@ if TYPE_CHECKING: 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 +180,7 @@ class BotBase(GroupMixin[None]): 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 +292,7 @@ class BotBase(GroupMixin[None]): 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 +304,8 @@ class BotBase(GroupMixin[None]): """ 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 +316,7 @@ class BotBase(GroupMixin[None]): 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 +328,8 @@ class BotBase(GroupMixin[None]): """ 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 @@ -1221,8 +1232,8 @@ class BotBase(GroupMixin[None]): 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 @@ -1242,8 +1253,7 @@ class BotBase(GroupMixin[None]): /, *, cls: Type[ContextT], - ) -> ContextT: - ... + ) -> ContextT: ... async def get_context( self, @@ -1320,15 +1330,15 @@ class BotBase(GroupMixin[None]): 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 @@ -1527,4 +1537,17 @@ 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 659d69ebb..371a9f8c1 100644 --- a/discord/ext/commands/cog.py +++ b/discord/ext/commands/cog.py @@ -21,6 +21,7 @@ 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 inspect @@ -44,18 +45,30 @@ from typing import ( 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 +182,7 @@ class CogMeta(type): __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/context.py b/discord/ext/commands/context.py index b5b96c15f..968fec419 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -21,6 +21,7 @@ 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 re @@ -48,7 +49,7 @@ if TYPE_CHECKING: 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 @@ -70,7 +71,7 @@ MISSING: Any = discord.utils.MISSING T = TypeVar('T') -CogT = TypeVar('CogT', bound="Cog") +CogT = TypeVar('CogT', bound='Cog') if TYPE_CHECKING: P = ParamSpec('P') @@ -424,8 +425,8 @@ class Context(discord.abc.Messageable, Generic[BotT]): # 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]: @@ -628,6 +629,38 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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, @@ -647,8 +680,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def reply( @@ -669,8 +701,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def reply( @@ -691,8 +722,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def reply( @@ -713,8 +743,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... async def reply(self, content: Optional[str] = None, **kwargs: Any) -> Message: """|coro| @@ -817,6 +846,38 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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, @@ -836,8 +897,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def send( @@ -858,8 +918,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def send( @@ -880,8 +939,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def send( @@ -902,8 +960,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): ephemeral: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... async def send( self, @@ -920,7 +977,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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 +1043,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): 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/ext/commands/converter.py b/discord/ext/commands/converter.py index d316f6ccc..baf22c626 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 cf328d9b3..fb68944bd 100644 --- a/discord/ext/commands/cooldowns.py +++ b/discord/ext/commands/cooldowns.py @@ -242,10 +242,10 @@ class MaxConcurrency: 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 372fcbedf..9ec0dd484 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -21,6 +21,7 @@ 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 asyncio @@ -43,6 +44,7 @@ from typing import ( TypeVar, Union, overload, + TypedDict, ) import re @@ -58,10 +60,39 @@ from .parameters import Parameter, Signature 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', @@ -368,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: @@ -393,7 +425,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): 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.') @@ -421,7 +453,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): 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) @@ -444,7 +476,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): 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: @@ -490,7 +522,10 @@ class Command(_BaseCommand, Generic[CogT, P, T]): @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 @@ -556,7 +591,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): 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 +1503,7 @@ class GroupMixin(Generic[CogT]): self: GroupMixin[CogT], name: str = ..., *args: Any, - **kwargs: Any, + **kwargs: Unpack[_CommandDecoratorKwargs], ) -> Callable[ [ Union[ @@ -1477,8 +1512,7 @@ class GroupMixin(Generic[CogT]): ] ], Command[CogT, P, T], - ]: - ... + ]: ... @overload def command( @@ -1486,7 +1520,7 @@ class GroupMixin(Generic[CogT]): 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[ @@ -1495,15 +1529,14 @@ class GroupMixin(Generic[CogT]): ] ], CommandT, - ]: - ... + ]: ... def command( self, 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 +1548,7 @@ class GroupMixin(Generic[CogT]): """ 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 +1560,7 @@ class GroupMixin(Generic[CogT]): self: GroupMixin[CogT], name: str = ..., *args: Any, - **kwargs: Any, + **kwargs: Unpack[_GroupDecoratorKwargs], ) -> Callable[ [ Union[ @@ -1537,8 +1569,7 @@ class GroupMixin(Generic[CogT]): ] ], Group[CogT, P, T], - ]: - ... + ]: ... @overload def group( @@ -1546,7 +1577,7 @@ class GroupMixin(Generic[CogT]): 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[ @@ -1555,15 +1586,14 @@ class GroupMixin(Generic[CogT]): ] ], GroupT, - ]: - ... + ]: ... def group( self, 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 +1605,7 @@ class GroupMixin(Generic[CogT]): """ 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 +1636,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) @@ -1702,42 +1732,35 @@ if TYPE_CHECKING: 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: Any, -) -> _CommandDecorator: - ... + **attrs: Unpack[_CommandDecoratorKwargs], +) -> _CommandDecorator: ... @overload 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[ @@ -1746,14 +1769,13 @@ def command( ] ], CommandT, -]: - ... +]: ... 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,16 +1820,15 @@ def command( @overload def group( name: str = ..., - **attrs: Any, -) -> _GroupDecorator: - ... + **attrs: Unpack[_GroupDecoratorKwargs], +) -> _GroupDecorator: ... @overload 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[ @@ -1816,14 +1837,13 @@ def group( ] ], GroupT, -]: - ... +]: ... 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 +2185,7 @@ def bot_has_any_role(*items: int) -> Callable[[T], T]: 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. @@ -2197,7 +2217,7 @@ def has_permissions(**perms: bool) -> 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.permissions @@ -2212,7 +2232,7 @@ def has_permissions(**perms: bool) -> Check[Any]: 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. @@ -2222,7 +2242,7 @@ def bot_has_permissions(**perms: bool) -> 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 @@ -2237,7 +2257,7 @@ def bot_has_permissions(**perms: bool) -> Check[Any]: 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. @@ -2249,7 +2269,7 @@ def has_guild_permissions(**perms: bool) -> 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: @@ -2266,7 +2286,7 @@ def has_guild_permissions(**perms: bool) -> Check[Any]: 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. @@ -2275,7 +2295,7 @@ def bot_has_guild_permissions(**perms: bool) -> 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: @@ -2515,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 feb4aee27..a962a4e73 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 0766ecae3..0b03b81d4 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 d06fbd8bf..dabbd9ef9 100644 --- a/discord/ext/commands/help.py +++ b/discord/ext/commands/help.py @@ -42,6 +42,7 @@ from typing import ( Iterable, Sequence, Mapping, + TypedDict, ) import discord.utils @@ -50,7 +51,7 @@ from .core import Group, Command, get_signature_parameters 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 @@ if TYPE_CHECKING: from .context import Context from .cog import Cog from .parameters import Parameter + from .core import _CommandKwargs from ._types import ( UserCheck, @@ -65,6 +67,30 @@ if TYPE_CHECKING: _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 @@ class _HelpCommandImpl(Command): 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 @@ class HelpCommand: 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,21 +1066,23 @@ 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) 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') - 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 99b537ca1..70d18f5d1 100644 --- a/discord/ext/commands/hybrid.py +++ b/discord/ext/commands/hybrid.py @@ -24,19 +24,7 @@ DEALINGS IN THE SOFTWARE. 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 .cog import Cog 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 @@ if TYPE_CHECKING: 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__ = ( @@ -256,7 +267,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 @@ -501,7 +512,7 @@ class HybridCommand(Command[CogT, P, T]): *, 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 @@ class HybridGroup(Group[CogT, P, T]): 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 @@ class HybridGroup(Group[CogT, P, T]): 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 @@ class HybridGroup(Group[CogT, P, T]): """ 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 @@ class HybridGroup(Group[CogT, P, T]): 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 @@ class HybridGroup(Group[CogT, P, T]): """ 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 hybrid_command( 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/ext/commands/parameters.py b/discord/ext/commands/parameters.py index 2640902a3..2d0de4f65 100644 --- a/discord/ext/commands/parameters.py +++ b/discord/ext/commands/parameters.py @@ -278,8 +278,7 @@ class ParameterAlias(Protocol): 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 e287221eb..53ca9d8b2 100644 --- a/discord/ext/commands/view.py +++ b/discord/ext/commands/view.py @@ -31,22 +31,22 @@ from .errors import UnexpectedQuoteError, InvalidEndOfQuotedStringError, Expecte # map from opening quotes to closing quotes _quotes = { '"': '"', - "‘": "’", - "‚": "‛", - "“": "”", - "„": "‟", - "⹂": "⹂", - "「": "」", - "『": "』", - "〝": "〞", - "﹁": "﹂", - "﹃": "﹄", - """: """, - "「": "」", - "«": "»", - "‹": "›", - "《": "》", - "〈": "〉", + '‘': '’', + '‚': '‛', + '“': '”', + '„': '‟', + '⹂': '⹂', + '「': '」', + '『': '』', + '〝': '〞', + '﹁': '﹂', + '﹃': '﹄', + '"': '"', + '「': '」', + '«': '»', + '‹': '›', + '《': '》', + '〈': '〉', } _all_quotes = set(_quotes.keys()) | set(_quotes.values()) diff --git a/discord/file.py b/discord/file.py index 1d8314fa0..ed1006054 100644 --- a/discord/file.py +++ b/discord/file.py @@ -199,6 +199,20 @@ class File: 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. + This is used in certain places such as embeds or components to refer + to an uploaded file via URL. + + .. note:: + Due to Discord's filename processing, the filename must be ASCII aphanumeric + with underscores, dashes, and periods. + + .. 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/flags.py b/discord/flags.py index 8a7a66954..5105a4156 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -40,12 +40,48 @@ from typing import ( 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__ = ( @@ -76,12 +112,10 @@ class flag_value: 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: @@ -500,6 +534,16 @@ class MessageFlags(BaseFlags): """ 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): @@ -755,12 +799,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: @@ -1416,7 +1460,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/gateway.py b/discord/gateway.py index a2c3da3d2..4e1f78c68 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -141,7 +141,7 @@ class KeepAliveHandler(threading.Thread): 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 @@ class KeepAliveHandler(threading.Thread): 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) @@ -160,9 +160,11 @@ class KeepAliveHandler(threading.Thread): 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']) diff --git a/discord/guild.py b/discord/guild.py index b03dbbea6..47a8b57c0 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -76,6 +76,7 @@ from .enums import ( AutoModRuleEventType, ForumOrderType, ForumLayoutType, + OnboardingMode, ) from .mixins import Hashable from .user import User @@ -91,6 +92,7 @@ from .sticker import GuildSticker 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 @@ if TYPE_CHECKING: 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] @@ -216,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: @@ -1293,8 +1296,7 @@ class Guild(Hashable): overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] = ..., category: Optional[Snowflake] = ..., **options: Any, - ) -> Coroutine[Any, Any, TextChannelPayload]: - ... + ) -> Coroutine[Any, Any, TextChannelPayload]: ... @overload def _create_channel( @@ -1304,8 +1306,7 @@ class Guild(Hashable): overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] = ..., category: Optional[Snowflake] = ..., **options: Any, - ) -> Coroutine[Any, Any, VoiceChannelPayload]: - ... + ) -> Coroutine[Any, Any, VoiceChannelPayload]: ... @overload def _create_channel( @@ -1315,8 +1316,7 @@ class Guild(Hashable): overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] = ..., category: Optional[Snowflake] = ..., **options: Any, - ) -> Coroutine[Any, Any, StageChannelPayload]: - ... + ) -> Coroutine[Any, Any, StageChannelPayload]: ... @overload def _create_channel( @@ -1326,8 +1326,7 @@ class Guild(Hashable): overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] = ..., category: Optional[Snowflake] = ..., **options: Any, - ) -> Coroutine[Any, Any, CategoryChannelPayload]: - ... + ) -> Coroutine[Any, Any, CategoryChannelPayload]: ... @overload def _create_channel( @@ -1337,8 +1336,7 @@ class Guild(Hashable): overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] = ..., category: Optional[Snowflake] = ..., **options: Any, - ) -> Coroutine[Any, Any, NewsChannelPayload]: - ... + ) -> Coroutine[Any, Any, NewsChannelPayload]: ... @overload def _create_channel( @@ -1348,8 +1346,7 @@ class Guild(Hashable): 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( @@ -1359,8 +1356,7 @@ class Guild(Hashable): overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] = ..., category: Optional[Snowflake] = ..., **options: Any, - ) -> Coroutine[Any, Any, ForumChannelPayload]: - ... + ) -> Coroutine[Any, Any, ForumChannelPayload]: ... @overload def _create_channel( @@ -1370,8 +1366,7 @@ class Guild(Hashable): overwrites: Mapping[Union[Role, Member, Object], PermissionOverwrite] = ..., category: Optional[Snowflake] = ..., **options: Any, - ) -> Coroutine[Any, Any, GuildChannelPayload]: - ... + ) -> Coroutine[Any, Any, GuildChannelPayload]: ... def _create_channel( self, @@ -1962,7 +1957,9 @@ class Guild(Hashable): ) 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 @@ -1974,11 +1971,6 @@ class Guild(Hashable): Leaves the guild. - .. note:: - - You cannot leave the guild that you own, you must delete it instead - via :meth:`delete`. - Raises -------- HTTPException @@ -1986,12 +1978,16 @@ class Guild(Hashable): """ 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 +2087,9 @@ class Guild(Hashable): 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 +2098,9 @@ class Guild(Hashable): 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 +2148,8 @@ class Guild(Hashable): 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. @@ -3236,8 +3240,7 @@ class Guild(Hashable): description: str = ..., image: bytes = ..., reason: Optional[str] = ..., - ) -> ScheduledEvent: - ... + ) -> ScheduledEvent: ... @overload async def create_scheduled_event( @@ -3252,8 +3255,7 @@ class Guild(Hashable): description: str = ..., image: bytes = ..., reason: Optional[str] = ..., - ) -> ScheduledEvent: - ... + ) -> ScheduledEvent: ... @overload async def create_scheduled_event( @@ -3267,8 +3269,7 @@ class Guild(Hashable): description: str = ..., image: bytes = ..., reason: Optional[str] = ..., - ) -> ScheduledEvent: - ... + ) -> ScheduledEvent: ... @overload async def create_scheduled_event( @@ -3282,8 +3283,7 @@ class Guild(Hashable): description: str = ..., image: bytes = ..., reason: Optional[str] = ..., - ) -> ScheduledEvent: - ... + ) -> ScheduledEvent: ... async def create_scheduled_event( self, @@ -3395,7 +3395,7 @@ class Guild(Hashable): 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: @@ -3650,8 +3650,7 @@ class Guild(Hashable): mentionable: bool = ..., secondary_colour: Optional[Union[Colour, int]] = ..., tertiary_colour: Optional[Union[Colour, int]] = ..., - ) -> Role: - ... + ) -> Role: ... @overload async def create_role( @@ -3666,8 +3665,7 @@ class Guild(Hashable): mentionable: bool = ..., secondary_color: Optional[Union[Colour, int]] = ..., tertiary_color: Optional[Union[Colour, int]] = ..., - ) -> Role: - ... + ) -> Role: ... async def create_role( self, @@ -4867,3 +4865,74 @@ class Guild(Hashable): 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 02fd1e136..7b82fddb6 100644 --- a/discord/http.py +++ b/discord/http.py @@ -57,16 +57,16 @@ from .file import File 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 ( @@ -81,6 +81,7 @@ if TYPE_CHECKING: invite, member, message, + onboarding, template, role, user, @@ -150,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, @@ -193,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'] = [] @@ -1040,7 +1047,7 @@ class HTTPClient: 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, ) @@ -1049,14 +1056,25 @@ class HTTPClient: 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 @@ -1863,12 +1881,10 @@ class HTTPClient: 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: @@ -2021,22 +2037,19 @@ class HTTPClient: @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)} @@ -2065,20 +2078,19 @@ class HTTPClient: @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 @@ -2148,8 +2160,7 @@ class HTTPClient: with_member: Literal[True], before: Optional[Snowflake] = ..., after: Optional[Snowflake] = ..., - ) -> Response[scheduled_event.ScheduledEventUsersWithMember]: - ... + ) -> Response[scheduled_event.ScheduledEventUsersWithMember]: ... @overload def get_scheduled_event_users( @@ -2160,8 +2171,7 @@ class HTTPClient: with_member: Literal[False], before: Optional[Snowflake] = ..., after: Optional[Snowflake] = ..., - ) -> Response[scheduled_event.ScheduledEventUsers]: - ... + ) -> Response[scheduled_event.ScheduledEventUsers]: ... @overload def get_scheduled_event_users( @@ -2172,8 +2182,7 @@ class HTTPClient: 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, @@ -2541,6 +2550,41 @@ class HTTPClient: ), ) + # 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/interactions.py b/discord/interactions.py index 82b35e392..e295de0c6 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -27,7 +27,7 @@ DEALINGS IN THE SOFTWARE. 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 @@ if TYPE_CHECKING: 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 @@ -95,8 +95,8 @@ if TYPE_CHECKING: GroupChannel, ] InteractionCallbackResource = Union[ - "InteractionMessage", - "InteractionCallbackActivityInstance", + 'InteractionMessage', + 'InteractionCallbackActivityInstance', ] MISSING: Any = utils.MISSING @@ -482,7 +482,7 @@ class Interaction(Generic[ClientT]): 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 @@ class Interaction(Generic[ClientT]): 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 @@ class Interaction(Generic[ClientT]): # 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 @@ -729,7 +735,6 @@ class InteractionCallbackResponse(Generic[ClientT]): resource = data.get('resource') if resource is not None: - self.type = try_enum(InteractionResponseType, resource['type']) message = resource.get('message') @@ -898,6 +903,21 @@ class InteractionResponse(Generic[ClientT]): ) 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 +934,24 @@ class InteractionResponse(Generic[ClientT]): 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 +976,7 @@ class InteractionResponse(Generic[ClientT]): 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 +1093,7 @@ class InteractionResponse(Generic[ClientT]): 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 +1123,15 @@ class InteractionResponse(Generic[ClientT]): 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 +1213,7 @@ class InteractionResponse(Generic[ClientT]): 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 +1426,17 @@ 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 +1448,19 @@ class InteractionMessage(Message): 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 +1486,15 @@ class InteractionMessage(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. + + .. 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/invite.py b/discord/invite.py index 362f97693..8b5088a89 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. @@ -417,7 +418,7 @@ class Invite(Hashable): 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 d6a26c7d0..db2e0e448 100644 --- a/discord/message.py +++ b/discord/message.py @@ -96,15 +96,14 @@ if TYPE_CHECKING: 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__ = ( @@ -449,7 +448,7 @@ class DeletedReferencedMessage: self._parent: MessageReference = parent def __repr__(self) -> str: - return f"" + return f'' @property def id(self) -> int: @@ -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 @@ class MessageSnapshot: 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) @@ -884,7 +883,9 @@ class MessageInteractionMetadata(Hashable): 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 @@ -1306,32 +1307,6 @@ class PartialMessage(Hashable): 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 +1316,7 @@ class PartialMessage(Hashable): 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 +1366,16 @@ class PartialMessage(Hashable): 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 +1414,8 @@ class PartialMessage(Hashable): 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 +1737,36 @@ class PartialMessage(Hashable): 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, @@ -1774,8 +1785,7 @@ class PartialMessage(Hashable): suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def reply( @@ -1795,8 +1805,7 @@ class PartialMessage(Hashable): suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def reply( @@ -1816,8 +1825,7 @@ class PartialMessage(Hashable): suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... @overload async def reply( @@ -1837,8 +1845,7 @@ class PartialMessage(Hashable): suppress_embeds: bool = ..., silent: bool = ..., poll: Poll = ..., - ) -> Message: - ... + ) -> Message: ... async def reply(self, content: Optional[str] = None, **kwargs: Any) -> Message: """|coro| @@ -2174,6 +2181,7 @@ class Message(PartialMessage, Hashable): 'call', 'purchase_notification', 'message_snapshots', + '_pinned_at', ) if TYPE_CHECKING: @@ -2213,6 +2221,8 @@ class Message(PartialMessage, Hashable): 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: @@ -2633,6 +2643,18 @@ class Message(PartialMessage, Hashable): # 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]: @@ -2700,19 +2722,19 @@ class Message(PartialMessage, Hashable): 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) @@ -2771,7 +2793,7 @@ class Message(PartialMessage, Hashable): 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 @@ -2841,39 +2863,11 @@ class Message(PartialMessage, Hashable): 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 '' - @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 +2878,7 @@ class Message(PartialMessage, Hashable): 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 +2936,16 @@ class Message(PartialMessage, Hashable): 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 +2991,7 @@ class Message(PartialMessage, Hashable): 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/onboarding.py b/discord/onboarding.py new file mode 100644 index 000000000..d26258c16 --- /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/opus.py b/discord/opus.py index 971675f73..3d996939b 100644 --- a/discord/opus.py +++ b/discord/opus.py @@ -72,7 +72,7 @@ __all__ = ( _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 @@ class Decoder(_OpusStruct): 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/partial_emoji.py b/discord/partial_emoji.py index 7d366949c..502202330 100644 --- a/discord/partial_emoji.py +++ b/discord/partial_emoji.py @@ -167,6 +167,12 @@ class PartialEmoji(_EmojiTag, AssetMixin): 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/permissions.py b/discord/permissions.py index c234ad5f3..729763785 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -24,7 +24,7 @@ DEALINGS IN THE SOFTWARE. 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,72 @@ __all__ = ( ) 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 + pin_messages: 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,32 +200,32 @@ 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.""" 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.""" @@ -187,7 +252,7 @@ class Permissions(BaseFlags): 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: @@ -202,6 +267,7 @@ class Permissions(BaseFlags): 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 @@ -260,7 +326,7 @@ class Permissions(BaseFlags): 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: @@ -308,8 +374,11 @@ class Permissions(BaseFlags): .. 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: @@ -391,7 +460,7 @@ class Permissions(BaseFlags): """ 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 +475,7 @@ class Permissions(BaseFlags): 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. @@ -503,7 +572,7 @@ class Permissions(BaseFlags): @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:: @@ -794,6 +863,14 @@ class Permissions(BaseFlags): """ 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) @@ -917,8 +994,9 @@ class PermissionOverwrite: send_polls: Optional[bool] create_polls: Optional[bool] use_external_apps: Optional[bool] + pin_messages: 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 +1058,7 @@ class PermissionOverwrite: """ 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/player.py b/discord/player.py index bad6da88e..6243c0417 100644 --- a/discord/player.py +++ b/discord/player.py @@ -21,6 +21,7 @@ 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 threading @@ -163,7 +164,7 @@ class FFmpegAudio(AudioSource): 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 @@ class FFmpegOpusAudio(FFmpegAudio): 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 @@ class FFmpegOpusAudio(FFmpegAudio): 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 @@ class FFmpegOpusAudio(FFmpegAudio): 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 @@ class AudioPlayer(threading.Thread): 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 6ab680abd..a191319d9 100644 --- a/discord/poll.py +++ b/discord/poll.py @@ -483,7 +483,7 @@ class Poll: 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 7fec2a09d..d8a93f03e 100644 --- a/discord/presences.py +++ b/discord/presences.py @@ -21,6 +21,7 @@ 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, Optional, Tuple diff --git a/discord/primary_guild.py b/discord/primary_guild.py index b65275a1f..85e40159a 100644 --- a/discord/primary_guild.py +++ b/discord/primary_guild.py @@ -83,7 +83,7 @@ class PrimaryGuild: @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/reaction.py b/discord/reaction.py index 9fd933b0a..060447e13 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/role.py b/discord/role.py index 18b024059..55996c3ae 100644 --- a/discord/role.py +++ b/discord/role.py @@ -429,10 +429,10 @@ class Role(Hashable): 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 @@ class Role(Hashable): 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 @@ class Role(Hashable): 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 f74ae6706..7372487fc 100644 --- a/discord/scheduled_event.py +++ b/discord/scheduled_event.py @@ -310,8 +310,7 @@ class ScheduledEvent(Hashable): status: EventStatus = ..., image: bytes = ..., reason: Optional[str] = ..., - ) -> ScheduledEvent: - ... + ) -> ScheduledEvent: ... @overload async def edit( @@ -327,8 +326,7 @@ class ScheduledEvent(Hashable): status: EventStatus = ..., image: bytes = ..., reason: Optional[str] = ..., - ) -> ScheduledEvent: - ... + ) -> ScheduledEvent: ... @overload async def edit( @@ -344,8 +342,7 @@ class ScheduledEvent(Hashable): image: bytes = ..., location: str, reason: Optional[str] = ..., - ) -> ScheduledEvent: - ... + ) -> ScheduledEvent: ... @overload async def edit( @@ -360,8 +357,7 @@ class ScheduledEvent(Hashable): status: EventStatus = ..., image: bytes = ..., reason: Optional[str] = ..., - ) -> ScheduledEvent: - ... + ) -> ScheduledEvent: ... @overload async def edit( @@ -376,8 +372,7 @@ class ScheduledEvent(Hashable): image: bytes = ..., location: str, reason: Optional[str] = ..., - ) -> ScheduledEvent: - ... + ) -> ScheduledEvent: ... async def edit( self, diff --git a/discord/shard.py b/discord/shard.py index cd10cc265..7198887cf 100644 --- a/discord/shard.py +++ b/discord/shard.py @@ -52,6 +52,12 @@ if TYPE_CHECKING: 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', @@ -313,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'] @@ -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) diff --git a/discord/sku.py b/discord/sku.py index 3516370b4..840759f5c 100644 --- a/discord/sku.py +++ b/discord/sku.py @@ -22,7 +22,6 @@ 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 AsyncIterator, Optional, TYPE_CHECKING diff --git a/discord/soundboard.py b/discord/soundboard.py index 3351aacb7..1773b9830 100644 --- a/discord/soundboard.py +++ b/discord/soundboard.py @@ -146,7 +146,7 @@ class SoundboardDefaultSound(BaseSoundboardSound): ('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 @@ class SoundboardSound(BaseSoundboardSound): ('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 223a09923..74922907d 100644 --- a/discord/state.py +++ b/discord/state.py @@ -71,7 +71,7 @@ from .flags import ApplicationFlags, Intents, MemberCacheFlags 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 @@ class ConnectionState(Generic[ClientT]): 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 @@ class ConnectionState(Generic[ClientT]): 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 @@ -1265,14 +1265,12 @@ class ConnectionState(Generic[ClientT]): 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 @@ -1792,7 +1790,7 @@ class ConnectionState(Generic[ClientT]): 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: @@ -1803,7 +1801,10 @@ class ConnectionState(Generic[ClientT]): 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/threads.py b/discord/threads.py index 0c8060193..1700a5e61 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 @@ class Thread(Messageable, Hashable): 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/activity.py b/discord/types/activity.py index f57334936..07f25d3bf 100644 --- a/discord/types/activity.py +++ b/discord/types/activity.py @@ -31,6 +31,7 @@ from .snowflake import Snowflake 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/discord/types/audit_log.py b/discord/types/audit_log.py index cc4ad8363..e2c3c1503 100644 --- a/discord/types/audit_log.py +++ b/discord/types/audit_log.py @@ -38,6 +38,7 @@ from .channel import ChannelType, DefaultReaction, PrivacyLevel, VideoQualityMod 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 @@ AuditLogEvent = Literal[ 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 @@ AuditLogChange = Union[ _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/automod.py b/discord/types/automod.py index 246b7ee6a..764fb1800 100644 --- a/discord/types/automod.py +++ b/discord/types/automod.py @@ -33,8 +33,7 @@ AutoModerationRuleEventType = Literal[1] AutoModerationTriggerPresets = Literal[1, 2, 3] -class Empty(TypedDict): - ... +class Empty(TypedDict): ... class _AutoModerationActionMetadataAlert(TypedDict): diff --git a/discord/types/channel.py b/discord/types/channel.py index 4b593e554..f3cc680b5 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/command.py b/discord/types/command.py index 7876ee6dd..0fbe7fbb5 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/components.py b/discord/types/components.py index 3b1295c13..bb75a918f 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -24,24 +24,31 @@ DEALINGS IN THE SOFTWARE. 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, 18] 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,11 +106,11 @@ class ChannelSelectComponent(SelectComponent): default_values: NotRequired[List[SelectDefaultValues]] -class TextInput(TypedDict): +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] @@ -113,10 +120,91 @@ class TextInput(TypedDict): 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]] +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] + + +class LabelComponent(ComponentBase): + type: Literal[18] + label: str + description: NotRequired[str] + component: Union[StringSelectComponent, TextInput] + + ActionRowChildComponent = Union[ButtonComponent, SelectMenu, TextInput] -Component = Union[ActionRow, ActionRowChildComponent] +ContainerChildComponent = Union[ + ActionRow, + TextComponent, + MediaGalleryComponent, + FileComponent, + SectionComponent, + SectionComponent, + ContainerComponent, + SeparatorComponent, + ThumbnailComponent, +] +Component = Union[ActionRowChildComponent, LabelComponent, ContainerChildComponent] diff --git a/discord/types/gateway.py b/discord/types/gateway.py index 7dca5badc..61959dc72 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/guild.py b/discord/types/guild.py index 0e328fed2..9146e8ba9 100644 --- a/discord/types/guild.py +++ b/discord/types/guild.py @@ -94,6 +94,8 @@ GuildFeature = Literal[ 'RAID_ALERTS_DISABLED', 'SOUNDBOARD', 'MORE_SOUNDBOARD', + 'GUESTS_ENABLED', + 'GUILD_TAGS', ] @@ -114,8 +116,7 @@ class _GuildPreviewUnique(TypedDict): approximate_presence_count: int -class GuildPreview(_BaseGuildPreview, _GuildPreviewUnique): - ... +class GuildPreview(_BaseGuildPreview, _GuildPreviewUnique): ... class Guild(_BaseGuildPreview): @@ -165,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/interactions.py b/discord/types/interactions.py index 464f2445f..f34166959 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -78,6 +78,14 @@ 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] + total_message_sent: int class ResolvedData(TypedDict, total=False): @@ -202,7 +210,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): @@ -210,7 +224,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/types/invite.py b/discord/types/invite.py index 47c972994..38e28f959 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,16 +65,17 @@ class Invite(IncompleteInvite, total=False): guild_scheduled_event: GuildScheduledEvent type: InviteType flags: NotRequired[int] + expires_at: Optional[str] -class InviteWithCounts(Invite, _GuildPreviewUnique): - ... +class InviteWithCounts(Invite, _GuildPreviewUnique): ... class GatewayInviteCreate(TypedDict): channel_id: Snowflake code: str created_at: str + expires_at: Optional[str] max_age: int max_uses: int temporary: bool diff --git a/discord/types/message.py b/discord/types/message.py index ae38db46f..dfb251f28 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -33,7 +33,7 @@ from .user import User 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] @@ -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 diff --git a/discord/types/onboarding.py b/discord/types/onboarding.py new file mode 100644 index 000000000..64f9c45c8 --- /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/types/scheduled_event.py b/discord/types/scheduled_event.py index 52200367f..1f558626c 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/threads.py b/discord/types/threads.py index f3b8f808c..1dce6fac1 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] diff --git a/discord/types/webhook.py b/discord/types/webhook.py index dd5eea156..a954689dc 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/__init__.py b/discord/ui/__init__.py index c5a51777c..2ce3655ed 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -16,3 +16,12 @@ from .button import * 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 * +from .label import * diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py new file mode 100644 index 000000000..1cdf4b6f1 --- /dev/null +++ b/discord/ui/action_row.py @@ -0,0 +1,588 @@ +""" +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 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 +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 + from .container import Container + + SelectCallbackDecorator = Callable[[ItemCallbackType['S', BaseSelectT]], BaseSelectT] + +S = TypeVar('S', bound=Union['ActionRow', 'Container', 'LayoutView'], 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 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. + + 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) + or (40) for the entire view. + """ + + 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__}') + + if self._view: + self._view._add_count(1) + + item._update_view(self.view) + item._parent = self + self._weight += 1 + self._children.append(item) + + 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: + self._view._add_count(-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: + self._view._add_count(-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 43bd3a8b0..f80065963 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -24,12 +24,13 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations +import copy from typing import Callable, Literal, Optional, TYPE_CHECKING, Tuple, TypeVar, Union import inspect import os -from .item import Item, ItemCallbackType +from .item import Item, ContainedItemCallbackType as ItemCallbackType from ..enums import ButtonStyle, ComponentType from ..partial_emoji import PartialEmoji, _EmojiTag from ..components import Button as ButtonComponent @@ -42,11 +43,14 @@ __all__ = ( if TYPE_CHECKING: from typing_extensions import Self - from .view import View + from .view import BaseView + from .action_row import ActionRow + from .container import Container from ..emoji import Emoji from ..types.components import ButtonComponent as ButtonComponentPayload -V = TypeVar('V', bound='View', covariant=True) +S = TypeVar('S', bound='Union[BaseView, Container, ActionRow]', covariant=True) +V = TypeVar('V', bound='BaseView', covariant=True) class Button(Item[V]): @@ -77,11 +81,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 +104,7 @@ class Button(Item[V]): 'emoji', 'row', 'sku_id', + 'id', ) def __init__( @@ -105,6 +118,7 @@ class Button(Item[V]): 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 +157,19 @@ class Button(Item[V]): 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 +266,7 @@ class Button(Item[V]): emoji=button.emoji, row=None, sku_id=button.sku_id, + id=button.id, ) @property @@ -262,6 +287,28 @@ class Button(Item[V]): 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 +318,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 +356,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 +380,7 @@ def button( '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 000000000..1dcdca6b2 --- /dev/null +++ b/discord/ui/container.py @@ -0,0 +1,375 @@ +""" +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, + ClassVar, + Coroutine, + Dict, + Generator, + List, + Literal, + Optional, + TypeVar, + Union, +) + +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 +from ..colour import Colour, Color + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..components import Container as ContainerComponent + from ..interactions import Interaction + +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 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. + + 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. + 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 + 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: + if item._has_children(): + self._view._add_count(-len(tuple(item.walk_children()))) # type: ignore + else: + self._view._add_count(-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: + self._view._add_count(-len(tuple(self.walk_children()))) + self._children.clear() + return self diff --git a/discord/ui/dynamic.py b/discord/ui/dynamic.py index 0b65e90f3..fb38b4b2e 100644 --- a/discord/ui/dynamic.py +++ b/discord/ui/dynamic.py @@ -38,14 +38,12 @@ if TYPE_CHECKING: 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 000000000..92b927ac0 --- /dev/null +++ b/discord/ui/file.py @@ -0,0 +1,159 @@ +""" +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 +from ..utils import MISSING +from ..file import File as SendableFile + +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`, :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. + 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, SendableFile], + *, + spoiler: bool = MISSING, + id: Optional[int] = None, + ) -> None: + super().__init__() + 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): + 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, 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 or File, 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 1ee549283..9218d840d 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -24,7 +24,8 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from typing import Any, Callable, Coroutine, Dict, Generic, Optional, TYPE_CHECKING, Tuple, Type, TypeVar +import copy +from typing import Any, Callable, Coroutine, Dict, Generic, Optional, TYPE_CHECKING, Union, Tuple, Type, TypeVar from ..interactions import Interaction from .._types import ClientT @@ -36,13 +37,20 @@ __all__ = ( # 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 + from .action_row import ActionRow + from .container import Container I = TypeVar('I', bound='Item[Any]') -V = TypeVar('V', bound='View', covariant=True) +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]): @@ -53,11 +61,20 @@ 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` + - :class:`discord.ui.Label` .. 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 +87,8 @@ class Item(Generic[V]): # 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 +99,9 @@ class Item(Generic[V]): 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 +114,9 @@ class Item(Generic[V]): 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 +128,10 @@ class Item(Generic[V]): @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 +145,43 @@ class Item(Generic[V]): @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, if applicable. Only available on items with children. + + .. 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| @@ -148,7 +210,8 @@ class Item(Generic[V]): .. 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/discord/ui/label.py b/discord/ui/label.py new file mode 100644 index 000000000..9357de425 --- /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/media_gallery.py b/discord/ui/media_gallery.py new file mode 100644 index 000000000..0a6aea151 --- /dev/null +++ b/discord/ui/media_gallery.py @@ -0,0 +1,263 @@ +""" +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 ..utils import MISSING +from ..file import File +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, File, UnfurledMediaItem], + description: Optional[str] = MISSING, + spoiler: bool = MISSING, + ) -> 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:`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. + 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, File, UnfurledMediaItem], + description: Optional[str] = MISSING, + spoiler: bool = MISSING, + ) -> 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:`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. + 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/modal.py b/discord/ui/modal.py index 630fc20f0..3900b49c2 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -34,6 +34,7 @@ from ..utils import MISSING, find 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,10 +171,12 @@ class Modal(View): 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']) + _log.debug('Modal interaction referencing unknown item custom_id %s. Discarding', component['custom_id']) continue item._refresh_state(interaction, component) # type: ignore @@ -194,10 +197,34 @@ class Modal(View): # 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: - 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/section.py b/discord/ui/section.py new file mode 100644 index 000000000..c6eaeabc7 --- /dev/null +++ b/discord/ui/section.py @@ -0,0 +1,261 @@ +""" +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. + + Attributes + ---------- + accessory: :class:`Item` + The section accessory. + """ + + __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 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. + + 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) or (40) + for the entire view. + """ + + if len(self._children) >= 3: + 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) + + 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._add_count(-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: + self._view._add_count(-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 1ef085cc5..a181357b7 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -42,7 +42,7 @@ from contextvars import ContextVar import inspect import os -from .item import Item, ItemCallbackType +from .item import Item, ContainedItemCallbackType as ItemCallbackType from ..enums import ChannelType, ComponentType, SelectDefaultValueType from ..partial_emoji import PartialEmoji from ..emoji import Emoji @@ -72,7 +72,8 @@ __all__ = ( 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 +102,15 @@ if TYPE_CHECKING: Thread, ] -V = TypeVar('V', bound='View', covariant=True) +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 +218,7 @@ class BaseSelect(Item[V]): 'min_values', 'max_values', 'disabled', + 'id', ) __component_attributes__: Tuple[str, ...] = ( 'custom_id', @@ -223,6 +226,7 @@ class BaseSelect(Item[V]): 'min_values', 'max_values', 'disabled', + 'id', ) def __init__( @@ -235,9 +239,11 @@ class BaseSelect(Item[V]): 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, + id: Optional[int] = None, ) -> None: super().__init__() self._provided_custom_id = custom_id is not MISSING @@ -252,14 +258,25 @@ class BaseSelect(Item[V]): 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, + 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({}) @@ -317,6 +334,18 @@ class BaseSelect(Item[V]): 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 @@ -332,11 +361,12 @@ class BaseSelect(Item[V]): 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) @@ -384,12 +414,24 @@ 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 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',) @@ -403,7 +445,9 @@ class Select(BaseSelect[V]): max_values: int = 1, options: List[SelectOption] = MISSING, disabled: bool = False, + required: bool = True, row: Optional[int] = None, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -412,8 +456,10 @@ class Select(BaseSelect[V]): min_values=min_values, max_values=max_values, disabled=disabled, + required=required, options=options, row=row, + id=id, ) @property @@ -545,6 +591,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 +613,7 @@ class UserSelect(BaseSelect[V]): disabled: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -569,6 +624,7 @@ class UserSelect(BaseSelect[V]): disabled=disabled, row=row, default_values=_handle_select_defaults(default_values, self.type), + id=id, ) @property @@ -637,6 +693,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 +715,7 @@ class RoleSelect(BaseSelect[V]): disabled: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -661,6 +726,7 @@ class RoleSelect(BaseSelect[V]): disabled=disabled, row=row, default_values=_handle_select_defaults(default_values, self.type), + id=id, ) @property @@ -725,6 +791,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 +813,7 @@ class MentionableSelect(BaseSelect[V]): disabled: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -749,6 +824,7 @@ class MentionableSelect(BaseSelect[V]): disabled=disabled, row=row, default_values=_handle_select_defaults(default_values, self.type), + id=id, ) @property @@ -819,6 +895,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 +921,7 @@ class ChannelSelect(BaseSelect[V]): disabled: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, + id: Optional[int] = None, ) -> None: super().__init__( self.type, @@ -848,6 +933,7 @@ class ChannelSelect(BaseSelect[V]): row=row, channel_types=channel_types, default_values=_handle_select_defaults(default_values, self.type), + id=id, ) @property @@ -899,8 +985,8 @@ def select( max_values: int = ..., disabled: bool = ..., row: Optional[int] = ..., -) -> SelectCallbackDecorator[V, SelectT]: - ... + id: Optional[int] = ..., +) -> SelectCallbackDecorator[S, SelectT]: ... @overload @@ -916,8 +1002,8 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., -) -> SelectCallbackDecorator[V, UserSelectT]: - ... + id: Optional[int] = ..., +) -> SelectCallbackDecorator[S, UserSelectT]: ... @overload @@ -933,8 +1019,8 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., -) -> SelectCallbackDecorator[V, RoleSelectT]: - ... + id: Optional[int] = ..., +) -> SelectCallbackDecorator[S, RoleSelectT]: ... @overload @@ -950,8 +1036,8 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., -) -> SelectCallbackDecorator[V, ChannelSelectT]: - ... + id: Optional[int] = ..., +) -> SelectCallbackDecorator[S, ChannelSelectT]: ... @overload @@ -967,8 +1053,8 @@ def select( disabled: bool = ..., default_values: Sequence[ValidDefaultValues] = ..., row: Optional[int] = ..., -) -> SelectCallbackDecorator[V, MentionableSelectT]: - ... + id: Optional[int] = ..., +) -> SelectCallbackDecorator[S, MentionableSelectT]: ... def select( @@ -983,7 +1069,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 +1128,10 @@ def select( 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 +1153,13 @@ def select( 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 +1175,7 @@ def select( '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 000000000..e6dc61f00 --- /dev/null +++ b/discord/ui/separator.py @@ -0,0 +1,125 @@ +""" +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 000000000..b6f908748 --- /dev/null +++ b/discord/ui/text_display.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 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, *, 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 96b4581f4..288e5efdc 100644 --- a/discord/ui/text_input.py +++ b/discord/ui/text_input.py @@ -29,7 +29,7 @@ from typing import TYPE_CHECKING, Literal, Optional, Tuple, TypeVar 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. @@ -92,18 +98,23 @@ 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__( self, *, - label: str, + label: Optional[str] = None, style: TextStyle = TextStyle.short, custom_id: str = MISSING, placeholder: Optional[str] = None, @@ -112,6 +123,7 @@ class TextInput(Item[V]): 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 +141,10 @@ class TextInput(Item[V]): 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 @@ -158,12 +172,14 @@ class TextInput(Item[V]): 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 @@ -241,6 +257,7 @@ class TextInput(Item[V]): 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 000000000..b921ecee7 --- /dev/null +++ b/discord/ui/thumbnail.py @@ -0,0 +1,144 @@ +""" +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 +from ..file import File +from ..utils import MISSING + +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.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. + 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, File, UnfurledMediaItem], + *, + 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] = None if description is MISSING else description + self.spoiler: bool = 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, 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}') + + @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 f27b71eeb..01f8543c6 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -23,7 +23,24 @@ DEALINGS IN THE SOFTWARE. """ 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, + Set, + Tuple, + Type, + Union, +) from functools import partial from itertools import groupby @@ -32,6 +49,7 @@ import logging import sys import time import os + from .item import Item, ItemCallbackType from .select import Select from .dynamic import DynamicItem @@ -41,11 +59,21 @@ from ..components import ( _component_factory, Button as ButtonComponent, SelectMenu as SelectComponent, + SectionComponent, + TextDisplay as TextDisplayComponent, + MediaGalleryComponent, + FileComponent, + SeparatorComponent, + ThumbnailComponent, + Container as ContainerComponent, + LabelComponent, ) +from ..utils import get as _utils_get, find as _utils_find # fmt: off __all__ = ( 'View', + 'LayoutView', ) # fmt: on @@ -56,11 +84,13 @@ if TYPE_CHECKING: from ..interactions import Interaction from ..message import Message - from ..types.components import 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 + ItemLike = Union[ItemCallbackType[Any, Any], Item[Any]] + _log = logging.getLogger(__name__) @@ -69,21 +99,65 @@ 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) + elif isinstance(component, LabelComponent): + from .label import Label + + item = Label.from_component(component) + else: + item = Item.from_component(component) + + item._parent = parent + return item class _ViewWeights: @@ -133,73 +207,66 @@ class _ViewWeights: 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) -> bool: + 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 +285,16 @@ class View: 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: @@ -265,19 +319,32 @@ class View: 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.""" return self._children.copy() + @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) -> View: - """Converts a message's components into a :class:`View`. + def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> Union[View, LayoutView]: + """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. 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 +354,43 @@ class View: 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) + 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 +410,24 @@ class View: 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__}') - self.__weights.add_item(item) + if item._is_v2() and not self._is_layout(): + raise ValueError('v2 items cannot be added to this view') - item._view = self + item._update_view(self) + added = 1 + + if item._has_children(): + added += len(tuple(item.walk_children())) # type: ignore + + self._add_count(added) self._children.append(item) return self @@ -357,7 +448,11 @@ class View: except ValueError: pass else: - self.__weights.remove_item(item) + removed = 1 + if item._has_children(): + removed += len(tuple(item.walk_children())) # type: ignore + self._add_count(-removed) + return self def clear_items(self) -> Self: @@ -367,9 +462,31 @@ class View: 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 +545,7 @@ class View: 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 +557,7 @@ class View: 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() @@ -459,17 +576,17 @@ class View: 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 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,21 +653,210 @@ class View: """ 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_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. + + 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 + + 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_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) -> 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: + 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 + + 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): # 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 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[View]: + def persistent_views(self) -> Sequence[BaseView]: # fmt: off views = { item.view.id: item.view @@ -561,6 +867,10 @@ class ViewStore: # 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__ @@ -571,7 +881,7 @@ class ViewStore: 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 +889,7 @@ class ViewStore: 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 +931,16 @@ class ViewStore: 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 +949,17 @@ class ViewStore: _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 @@ -661,9 +981,11 @@ class ViewStore: 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: @@ -681,7 +1003,7 @@ class ViewStore: 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) @@ -710,7 +1032,9 @@ class ViewStore: 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, @@ -720,10 +1044,10 @@ class ViewStore: ) -> 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 - 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 @@ -733,14 +1057,14 @@ class ViewStore: 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/user.py b/discord/user.py index 636c909f3..751437532 100644 --- a/discord/user.py +++ b/discord/user.py @@ -101,8 +101,8 @@ class BaseUser(_UserTag): def __repr__(self) -> str: return ( - f"" + f'' ) def __str__(self) -> str: diff --git a/discord/utils.py b/discord/utils.py index bcdf922b4..dc90c8b95 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -158,8 +158,7 @@ if TYPE_CHECKING: 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 @@ class CachedSlotProperty(Generic[T, T_co]): 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 @@ class SequenceProxy(Sequence[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 @@ class SequenceProxy(Sequence[T_co]): @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 deprecated(instead: Optional[str] = None) -> Callable[[Callable[P, T]], Call 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 @@ _MARKDOWN_ESCAPE_SUBREGEX = '|'.join(r'\{0}(?=([\s\S]*((?(?:>>)?\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 795434e1e..b0f3e951b 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 @@ class VoiceClient(VoiceProtocol): .. 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 @@ class VoiceClient(VoiceProtocol): .. 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 d2cc0ebc1..5e78c7851 100644 --- a/discord/voice_state.py +++ b/discord/voice_state.py @@ -148,7 +148,7 @@ class SocketReader(threading.Thread): 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 dbb3f1ed9..9d4fa0da6 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -71,7 +71,7 @@ if TYPE_CHECKING: 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'] = [] @@ -755,7 +762,7 @@ class _WebhookState: 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') @@ -802,7 +809,7 @@ class WebhookMessage(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, ) -> WebhookMessage: """|coro| @@ -1317,7 +1324,7 @@ class Webhook(BaseWebhook): @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, @@ -1598,6 +1605,44 @@ class Webhook(BaseWebhook): # 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, @@ -1620,8 +1665,7 @@ class Webhook(BaseWebhook): silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, - ) -> WebhookMessage: - ... + ) -> WebhookMessage: ... @overload async def send( @@ -1645,8 +1689,7 @@ class Webhook(BaseWebhook): silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, - ) -> None: - ... + ) -> None: ... async def send( self, @@ -1661,7 +1704,7 @@ class Webhook(BaseWebhook): 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 +1770,7 @@ class Webhook(BaseWebhook): 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 +1974,31 @@ class Webhook(BaseWebhook): ) 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 +2007,7 @@ class Webhook(BaseWebhook): 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 +2046,17 @@ class Webhook(BaseWebhook): 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 +2120,7 @@ class Webhook(BaseWebhook): ) 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 171931b12..1786496fa 100644 --- a/discord/webhook/sync.py +++ b/discord/webhook/sync.py @@ -66,7 +66,7 @@ if TYPE_CHECKING: 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,42 @@ class SyncWebhook(BaseWebhook): # 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,8 +912,8 @@ class SyncWebhook(BaseWebhook): silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, - ) -> SyncWebhookMessage: - ... + view: View = MISSING, + ) -> SyncWebhookMessage: ... @overload def send( @@ -899,8 +935,8 @@ class SyncWebhook(BaseWebhook): silent: bool = MISSING, applied_tags: List[ForumTag] = MISSING, poll: Poll = MISSING, - ) -> None: - ... + view: View = MISSING, + ) -> None: ... def send( self, @@ -921,7 +957,7 @@ class SyncWebhook(BaseWebhook): 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 +1030,8 @@ class SyncWebhook(BaseWebhook): 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 +1179,31 @@ class SyncWebhook(BaseWebhook): ) 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 +1212,7 @@ class SyncWebhook(BaseWebhook): 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 +1239,13 @@ class SyncWebhook(BaseWebhook): 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/discord/widget.py b/discord/widget.py index cdb883fd9..539f22f8d 100644 --- a/discord/widget.py +++ b/discord/widget.py @@ -195,7 +195,7 @@ class WidgetMember(BaseUser): 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 @@ class Widget: @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/docs/api.rst b/docs/api.rst index 00878f393..7a75ba258 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. @@ -3506,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 @@ -3898,6 +4005,75 @@ 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. + +.. 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. + + + +.. 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 @@ -4144,9 +4320,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 @@ -4519,7 +4695,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` @@ -4539,7 +4715,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`, @@ -4551,7 +4727,7 @@ AuditLogDiff The actions to take when an automod rule is triggered. - :type: List[AutoModRuleAction] + :type: List[:class:`AutoModRuleAction`] .. attribute:: exempt_roles @@ -4649,6 +4825,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 @@ -5272,6 +5513,31 @@ GuildSticker .. autoclass:: GuildSticker() :members: +Onboarding +~~~~~~~~~~~ + +.. attributetable:: Onboarding + +.. autoclass:: Onboarding() + :members: + +OnboardingPrompt +~~~~~~~~~~~~~~~~~ + +.. attributetable:: OnboardingPrompt + +.. autoclass:: OnboardingPrompt() + :members: + + +OnboardingPromptOption +~~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: OnboardingPromptOption + +.. autoclass:: OnboardingPromptOption() + :members: + BaseSoundboardSound ~~~~~~~~~~~~~~~~~~~~~~~ @@ -5480,8 +5746,6 @@ PollAnswer .. autoclass:: PollAnswer() :members: -.. _discord_api_data: - MessageSnapshot ~~~~~~~~~~~~~~~~~ @@ -5506,6 +5770,16 @@ PrimaryGuild .. autoclass:: PrimaryGuild() :members: +CallMessage +~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: CallMessage + +.. autoclass:: CallMessage() + :members: + +.. _discord_api_data: + Data Classes -------------- @@ -5817,12 +6091,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/faq.rst b/docs/faq.rst index 16d03362a..76e2e1b8a 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)) diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index 294a3b13a..1feeca879 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -113,6 +113,86 @@ TextInput :members: :inherited-members: +LabelComponent +~~~~~~~~~~~~~~~~ + +.. attributetable:: LabelComponent + +.. autoclass:: LabelComponent() + :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 +379,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 +413,54 @@ 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 + + .. attribute:: label + + Represents a label container component, usually in a modal. + + .. versionadded:: 2.6 + .. class:: ButtonStyle Represents the style of the button component. @@ -467,6 +595,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 +623,16 @@ View .. autoclass:: discord.ui.View :members: + :inherited-members: + +LayoutView +~~~~~~~~~~ + +.. attributetable:: discord.ui.LayoutView + +.. autoclass:: discord.ui.LayoutView + :members: + :inherited-members: Modal ~~~~~~ @@ -491,6 +642,7 @@ Modal .. autoclass:: discord.ui.Modal :members: :inherited-members: + :exclude-members: from_message Item ~~~~~~~ @@ -585,6 +737,105 @@ TextInput .. autoclass:: discord.ui.TextInput :members: :inherited-members: + :exclude-members: callback, interaction_check + + +Container +~~~~~~~~~ + +.. attributetable:: discord.ui.Container + +.. autoclass:: discord.ui.Container + :members: + :inherited-members: + :exclude-members: callback + + +File +~~~~ + +.. attributetable:: discord.ui.File + +.. autoclass:: discord.ui.File + :members: + :inherited-members: + :exclude-members: callback, interaction_check + +Label +~~~~~~ + +.. attributetable:: discord.ui.Label + +.. autoclass:: discord.ui.Label + :members: + :inherited-members: + :exclude-members: callback, interaction_check + + +MediaGallery +~~~~~~~~~~~~ + +.. attributetable:: discord.ui.MediaGallery + +.. autoclass:: discord.ui.MediaGallery + :members: + :inherited-members: + :exclude-members: callback, interaction_check + + +Section +~~~~~~~ + +.. attributetable:: discord.ui.Section + +.. autoclass:: discord.ui.Section + :members: + :inherited-members: + :exclude-members: callback + + +Separator +~~~~~~~~~ + +.. attributetable:: discord.ui.Separator + +.. autoclass:: discord.ui.Separator + :members: + :inherited-members: + :exclude-members: callback, interaction_check + + +TextDisplay +~~~~~~~~~~~ + +.. attributetable:: discord.ui.TextDisplay + +.. autoclass:: discord.ui.TextDisplay + :members: + :inherited-members: + :exclude-members: callback, interaction_check + + +Thumbnail +~~~~~~~~~ + +.. attributetable:: discord.ui.Thumbnail + +.. autoclass:: discord.ui.Thumbnail + :members: + :inherited-members: + :exclude-members: callback, interaction_check + + +ActionRow +~~~~~~~~~ + +.. attributetable:: discord.ui.ActionRow + +.. autoclass:: discord.ui.ActionRow + :members: + :inherited-members: + :exclude-members: callback .. _discord_app_commands: diff --git a/docs/whats_new.rst b/docs/whats_new.rst index 44db8c3d4..c5c7fa2a9 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 diff --git a/examples/advanced_startup.py b/examples/advanced_startup.py index 4a452188d..c06edb53d 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 @@ -32,7 +31,6 @@ class CustomBot(commands.Bot): 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: @@ -54,7 +52,6 @@ class CustomBot(commands.Bot): 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 f646643d0..fce7c4654 100644 --- a/examples/app_commands/basic.py +++ b/examples/app_commands/basic.py @@ -8,6 +8,9 @@ MY_GUILD = discord.Object(id=0) # replace with your guild id 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,21 +75,34 @@ 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 # 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): # 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 +113,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 2fb1231c4..b6463327b 100644 --- a/examples/app_commands/transformers.py +++ b/examples/app_commands/transformers.py @@ -12,6 +12,9 @@ MY_GUILD = discord.Object(id=0) # replace with your guild id 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) @@ -57,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') @@ -80,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/background_task.py b/examples/background_task.py index 657aeb34b..40d543c51 100644 --- a/examples/background_task.py +++ b/examples/background_task.py @@ -4,6 +4,9 @@ import discord 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 @@ class MyClient(discord.Client): @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 8e9f3ce65..33b19bb38 100644 --- a/examples/background_task_asyncio.py +++ b/examples/background_task_asyncio.py @@ -3,6 +3,9 @@ import asyncio 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 @@ class MyClient(discord.Client): 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 6e6aee182..94324fe03 100644 --- a/examples/basic_bot.py +++ b/examples/basic_bot.py @@ -4,10 +4,10 @@ import discord 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 @@ -18,6 +18,9 @@ bot = commands.Bot(command_prefix='?', description=description, intents=intents) @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 c0759e2a0..37a13a257 100644 --- a/examples/basic_voice.py +++ b/examples/basic_voice.py @@ -25,10 +25,6 @@ ytdl_format_options = { '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 @@ class YTDLSource(discord.PCMVolumeTransformer): 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): @@ -101,10 +97,10 @@ class Music(commands.Cog): """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): @@ -120,8 +116,8 @@ class Music(commands.Cog): 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() @@ -130,7 +126,7 @@ intents = discord.Intents.default() 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, ) @@ -138,6 +134,9 @@ bot = commands.Bot( @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 f8cae5675..014f25b5a 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. @@ -83,8 +83,14 @@ class ChannelOrMemberConverter(commands.Converter): 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/custom_context.py b/examples/custom_context.py index f7f74b1c3..57dd8cd2a 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/deleted.py b/examples/deleted.py index 97dac4631..1ac179643 100644 --- a/examples/deleted.py +++ b/examples/deleted.py @@ -4,6 +4,9 @@ import discord 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 5b089109c..422502a6e 100644 --- a/examples/edits.py +++ b/examples/edits.py @@ -5,6 +5,9 @@ import asyncio 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 dd6c26ca9..ff2a0bfc8 100644 --- a/examples/guessing_game.py +++ b/examples/guessing_game.py @@ -6,6 +6,9 @@ import asyncio 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 edde8435b..bbc17cb49 100644 --- a/examples/modals/basic.py +++ b/examples/modals/basic.py @@ -9,6 +9,9 @@ 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 @@ -67,7 +70,7 @@ class Feedback(discord.ui.Modal, title='Feedback'): 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 new file mode 100644 index 000000000..1f3087f52 --- /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') diff --git a/examples/new_member.py b/examples/new_member.py index 7cc84251e..60fe94b20 100644 --- a/examples/new_member.py +++ b/examples/new_member.py @@ -4,6 +4,9 @@ import discord 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 99c4d17b6..2ab7e9f65 100644 --- a/examples/reaction_roles.py +++ b/examples/reaction_roles.py @@ -4,6 +4,9 @@ import discord 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 @@ class MyClient(discord.Client): # 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 f2ccb4a7c..94154894a 100644 --- a/examples/reply.py +++ b/examples/reply.py @@ -4,6 +4,9 @@ import discord 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 434b5f0aa..7a649f4c0 100644 --- a/examples/secret.py +++ b/examples/secret.py @@ -5,7 +5,8 @@ from discord.ext import commands 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) @@ -52,6 +53,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 +73,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 +92,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 5500dc2a6..7695aac70 100644 --- a/examples/views/confirm.py +++ b/examples/views/confirm.py @@ -6,6 +6,8 @@ import discord 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 e3cd40e81..98c1e2691 100644 --- a/examples/views/counter.py +++ b/examples/views/counter.py @@ -6,6 +6,9 @@ import discord 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 @@ -19,7 +22,6 @@ class CounterBot(commands.Bot): # 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 eb055eebd..0c9dab2fe 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='🟥'), @@ -38,6 +38,9 @@ class DropdownView(discord.ui.View): 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 cfb02ee5d..531cb24b0 100644 --- a/examples/views/dynamic_counter.py +++ b/examples/views/dynamic_counter.py @@ -15,6 +15,7 @@ import re # `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], @@ -70,6 +71,9 @@ class DynamicCounter( 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/embed_like.py b/examples/views/embed_like.py new file mode 100644 index 000000000..9181d0fdb --- /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') diff --git a/examples/views/ephemeral.py b/examples/views/ephemeral.py index 5a5fbffab..b5966a8e1 100644 --- a/examples/views/ephemeral.py +++ b/examples/views/ephemeral.py @@ -6,6 +6,9 @@ import discord 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 @@ -19,7 +22,6 @@ class EphemeralCounterBot(commands.Bot): # 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/layout.py b/examples/views/layout.py new file mode 100644 index 000000000..a5d161329 --- /dev/null +++ b/examples/views/layout.py @@ -0,0 +1,50 @@ +# This example requires the 'message_content' privileged intent to function. + +from discord.ext import commands + +import discord + + +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 + + 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') diff --git a/examples/views/link.py b/examples/views/link.py index 3838fb72f..13394891c 100644 --- a/examples/views/link.py +++ b/examples/views/link.py @@ -7,6 +7,9 @@ from urllib.parse import quote_plus 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 14b267190..90050ea03 100644 --- a/examples/views/persistent.py +++ b/examples/views/persistent.py @@ -64,6 +64,9 @@ class DynamicButton(discord.ui.DynamicItem[discord.ui.Button], template=r'button 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/settings.py b/examples/views/settings.py new file mode 100644 index 000000000..acf8779b3 --- /dev/null +++ b/examples/views/settings.py @@ -0,0 +1,257 @@ +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: + 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: + 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') diff --git a/examples/views/tic_tac_toe.py b/examples/views/tic_tac_toe.py index eff638ae3..3a70650ce 100644 --- a/examples/views/tic_tac_toe.py +++ b/examples/views/tic_tac_toe.py @@ -4,6 +4,7 @@ from typing import List 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. @@ -121,6 +122,9 @@ class TicTacToe(discord.ui.View): 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 diff --git a/pyproject.toml b/pyproject.toml index 92ccb7381..20d117b01 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",