diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 68f037c31..9b631df15 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -34,6 +34,10 @@ If the bug report is missing this information then it'll take us longer to fix t Submitting a pull request is fairly simple, just make sure it focuses on a single aspect and doesn't manage to have scope creep and it's probably good to go. It would be incredibly lovely if the style is consistent to that found in the project. This project follows PEP-8 guidelines (mostly) with a column limit of 125. +### AI Contributions + +This repository does not accept any AI contributions at all. Using tools like Claude Code, Copilot, Gemini, ChatGPT, OpenAI Codex, etc. are simply blanket banned. AI contributions are typically nonsensical and just take up very valuable review time and thus are banned. Pull requests that are made with AI tools will be instantly closed without review, no matter how small the changeset is. + ### Git Commit Guidelines - Use present tense (e.g. "Add feature" not "Added feature") diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 73992a155..2c9f648a8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -45,4 +45,4 @@ jobs: - name: Run ruff if: ${{ always() && steps.install-deps.outcome == 'success' }} run: | - ruff format --check discord examples + ruff format --check diff --git a/.gitignore b/.gitignore index 0f7db0f40..62782dbcf 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ docs/crowdin.py *.mo /.coverage build/* +uv.lock* +pylock*.toml \ No newline at end of file diff --git a/discord/__init__.py b/discord/__init__.py index 3279f8b8c..bfa652b91 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.7.0a' +__version__ = '2.8.0a' __path__ = __import__('pkgutil').extend_path(__path__, __name__) @@ -86,7 +86,7 @@ class VersionInfo(NamedTuple): serial: int -version_info: VersionInfo = VersionInfo(major=2, minor=7, micro=0, releaselevel='alpha', serial=0) +version_info: VersionInfo = VersionInfo(major=2, minor=8, micro=0, releaselevel='alpha', serial=0) logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/discord/__main__.py b/discord/__main__.py index 455c5e8ed..1b8d50211 100644 --- a/discord/__main__.py +++ b/discord/__main__.py @@ -48,6 +48,14 @@ def show_version() -> None: entries.append(f' - discord.py metadata: v{version}') entries.append(f'- aiohttp v{aiohttp.__version__}') + + try: + import davey # type: ignore + except ImportError: + entries.append('- davey not found') + else: + entries.append(f'- davey v{davey.__version__}') + uname = platform.uname() entries.append('- system info: {0.system} {0.release} {0.version}'.format(uname)) print('\n'.join(entries)) diff --git a/discord/abc.py b/discord/abc.py index 9b7d63e01..2329e49c9 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -195,6 +195,9 @@ async def _purge_helper( count = 0 await asyncio.sleep(1) + if not message.type.is_deletable(): + continue + if not check(message): continue @@ -818,7 +821,7 @@ class GuildChannel: if obj.is_default(): return base - overwrite = utils.get(self._overwrites, type=_Overwrites.ROLE, id=obj.id) + overwrite = utils.find(lambda ow: ow.type == _Overwrites.ROLE and ow.id == obj.id, self._overwrites) if overwrite is not None: base.handle_overwrite(overwrite.allow, overwrite.deny) diff --git a/discord/app_commands/commands.py b/discord/app_commands/commands.py index 36d07d41c..4000f2968 100644 --- a/discord/app_commands/commands.py +++ b/discord/app_commands/commands.py @@ -58,7 +58,16 @@ from ..message import Message from ..user import User from ..member import Member from ..permissions import Permissions -from ..utils import resolve_annotation, MISSING, is_inside_class, maybe_coroutine, async_all, _shorten, _to_kebab_case +from ..utils import ( + resolve_annotation, + MISSING, + is_inside_class, + maybe_coroutine, + async_all, + _iscoroutinefunction, + _shorten, + _to_kebab_case, +) if TYPE_CHECKING: from typing_extensions import ParamSpec, Concatenate, Unpack @@ -346,7 +355,7 @@ def _populate_autocomplete(params: Dict[str, CommandParameter], autocomplete: Di if callback is MISSING: continue - if not inspect.iscoroutinefunction(callback): + if not _iscoroutinefunction(callback): raise TypeError('autocomplete callback must be a coroutine function') if param.type not in (AppCommandOptionType.string, AppCommandOptionType.number, AppCommandOptionType.integer): @@ -1037,7 +1046,7 @@ class Command(Generic[GroupT, P, T]): The coroutine passed is not actually a coroutine. """ - if not inspect.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError('The error handler must be a coroutine.') self.on_error = coro @@ -1098,7 +1107,7 @@ class Command(Generic[GroupT, P, T]): """ def decorator(coro: AutocompleteCallback[GroupT, ChoiceT]) -> AutocompleteCallback[GroupT, ChoiceT]: - if not inspect.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError('The autocomplete callback must be a coroutine function.') try: @@ -1347,7 +1356,7 @@ class ContextMenu: The coroutine passed is not actually a coroutine. """ - if not inspect.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError('The error handler must be a coroutine.') self.on_error = coro @@ -1802,7 +1811,7 @@ class Group: yield from command.walk_commands() @mark_overrideable - async def on_error(self, interaction: Interaction, error: AppCommandError, /) -> None: + async def on_error(self, interaction: Interaction[ClientT], error: AppCommandError, /) -> None: """|coro| A callback that is called when a child's command raises an :exc:`AppCommandError`. @@ -1840,7 +1849,7 @@ class Group: The coroutine passed is not actually a coroutine, or is an invalid coroutine. """ - if not inspect.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError('The error handler must be a coroutine.') params = inspect.signature(coro).parameters @@ -1850,7 +1859,7 @@ class Group: self.on_error = coro # type: ignore return coro - async def interaction_check(self, interaction: Interaction, /) -> bool: + async def interaction_check(self, interaction: Interaction[ClientT], /) -> bool: """|coro| A callback that is called when an interaction happens within the group @@ -1990,7 +1999,7 @@ class Group: """ def decorator(func: CommandCallback[GroupT, P, T]) -> Command[GroupT, P, T]: - if not inspect.iscoroutinefunction(func): + if not _iscoroutinefunction(func): raise TypeError('command function must be a coroutine function') if description is MISSING: @@ -2051,7 +2060,7 @@ def command( """ def decorator(func: CommandCallback[GroupT, P, T]) -> Command[GroupT, P, T]: - if not inspect.iscoroutinefunction(func): + if not _iscoroutinefunction(func): raise TypeError('command function must be a coroutine function') if description is MISSING: @@ -2123,7 +2132,7 @@ def context_menu( """ def decorator(func: ContextMenuCallback) -> ContextMenu: - if not inspect.iscoroutinefunction(func): + if not _iscoroutinefunction(func): raise TypeError('context menu function must be a coroutine function') actual_name = func.__name__.title() if name is MISSING else name @@ -2180,8 +2189,9 @@ def describe(**parameters: Union[str, locale_str]) -> Callable[[T], T]: ''' def decorator(inner: T) -> T: - if isinstance(inner, Command): - _populate_descriptions(inner._params, parameters) + unwrapped = getattr(inner, '__discord_app_commands_unwrap__', inner) or inner + if isinstance(unwrapped, Command): + _populate_descriptions(unwrapped._params, parameters) else: try: inner.__discord_app_commands_param_description__.update(parameters) # type: ignore # Runtime attribute access @@ -2223,8 +2233,9 @@ def rename(**parameters: Union[str, locale_str]) -> Callable[[T], T]: """ def decorator(inner: T) -> T: - if isinstance(inner, Command): - _populate_renames(inner._params, parameters) + unwrapped = getattr(inner, '__discord_app_commands_unwrap__', inner) or inner + if isinstance(unwrapped, Command): + _populate_renames(unwrapped._params, parameters) else: try: inner.__discord_app_commands_param_rename__.update(parameters) # type: ignore # Runtime attribute access @@ -2292,8 +2303,9 @@ def choices(**parameters: List[Choice[ChoiceT]]) -> Callable[[T], T]: """ def decorator(inner: T) -> T: - if isinstance(inner, Command): - _populate_choices(inner._params, parameters) + unwrapped = getattr(inner, '__discord_app_commands_unwrap__', inner) or inner + if isinstance(unwrapped, Command): + _populate_choices(unwrapped._params, parameters) else: try: inner.__discord_app_commands_param_choices__.update(parameters) # type: ignore # Runtime attribute access @@ -2351,8 +2363,9 @@ def autocomplete(**parameters: AutocompleteCallback[GroupT, ChoiceT]) -> Callabl """ def decorator(inner: T) -> T: - if isinstance(inner, Command): - _populate_autocomplete(inner._params, parameters) + unwrapped = getattr(inner, '__discord_app_commands_unwrap__', inner) or inner + if isinstance(unwrapped, Command): + _populate_autocomplete(unwrapped._params, parameters) else: try: inner.__discord_app_commands_param_autocomplete__.update(parameters) # type: ignore # Runtime attribute access @@ -2408,13 +2421,14 @@ def guilds(*guild_ids: Union[Snowflake, int]) -> Callable[[T], T]: defaults: List[int] = [g if isinstance(g, int) else g.id for g in guild_ids] def decorator(inner: T) -> T: - if isinstance(inner, (Group, ContextMenu)): - inner._guild_ids = defaults - elif isinstance(inner, Command): - if inner.parent is not None: + unwrapped = getattr(inner, '__discord_app_commands_unwrap__', inner) or inner + if isinstance(unwrapped, (Group, ContextMenu)): + unwrapped._guild_ids = defaults + elif isinstance(unwrapped, Command): + if unwrapped.parent is not None: raise ValueError('child commands of a group cannot have default guilds set') - inner._guild_ids = defaults + unwrapped._guild_ids = defaults else: # Runtime attribute assignment inner.__discord_app_commands_default_guilds__ = defaults # type: ignore @@ -2470,13 +2484,14 @@ def check(predicate: Check) -> Callable[[T], T]: """ def decorator(func: CheckInputParameter) -> CheckInputParameter: - if isinstance(func, (Command, ContextMenu)): - func.checks.append(predicate) + unwrapped = getattr(func, '__discord_app_commands_unwrap__', func) or func + if isinstance(unwrapped, (Command, ContextMenu)): + unwrapped.checks.append(predicate) else: if not hasattr(func, '__discord_app_commands_checks__'): - func.__discord_app_commands_checks__ = [] + func.__discord_app_commands_checks__ = [] # type: ignore # Runtime attribute assignment - func.__discord_app_commands_checks__.append(predicate) + func.__discord_app_commands_checks__.append(predicate) # type: ignore # Runtime attribute access return func @@ -2513,10 +2528,11 @@ def guild_only(func: Optional[T] = None) -> Union[T, Callable[[T], T]]: """ def inner(f: T) -> T: - if isinstance(f, (Command, Group, ContextMenu)): - f.guild_only = True - allowed_contexts = f.allowed_contexts or AppCommandContext() - f.allowed_contexts = allowed_contexts + unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f + if isinstance(unwrapped, (Command, Group, ContextMenu)): + unwrapped.guild_only = True + allowed_contexts = unwrapped.allowed_contexts or AppCommandContext() + unwrapped.allowed_contexts = allowed_contexts else: f.__discord_app_commands_guild_only__ = True # type: ignore # Runtime attribute assignment @@ -2567,10 +2583,11 @@ def private_channel_only(func: Optional[T] = None) -> Union[T, Callable[[T], T]] """ def inner(f: T) -> T: - if isinstance(f, (Command, Group, ContextMenu)): - f.guild_only = False - allowed_contexts = f.allowed_contexts or AppCommandContext() - f.allowed_contexts = allowed_contexts + unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f + if isinstance(unwrapped, (Command, Group, ContextMenu)): + unwrapped.guild_only = False + allowed_contexts = unwrapped.allowed_contexts or AppCommandContext() + unwrapped.allowed_contexts = allowed_contexts else: allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext() f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment @@ -2617,10 +2634,11 @@ def dm_only(func: Optional[T] = None) -> Union[T, Callable[[T], T]]: """ def inner(f: T) -> T: - if isinstance(f, (Command, Group, ContextMenu)): - f.guild_only = False - allowed_contexts = f.allowed_contexts or AppCommandContext() - f.allowed_contexts = allowed_contexts + unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f + if isinstance(unwrapped, (Command, Group, ContextMenu)): + unwrapped.guild_only = False + allowed_contexts = unwrapped.allowed_contexts or AppCommandContext() + unwrapped.allowed_contexts = allowed_contexts else: allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext() f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment @@ -2658,10 +2676,11 @@ def allowed_contexts(guilds: bool = MISSING, dms: bool = MISSING, private_channe """ def inner(f: T) -> T: - if isinstance(f, (Command, Group, ContextMenu)): - f.guild_only = False - allowed_contexts = f.allowed_contexts or AppCommandContext() - f.allowed_contexts = allowed_contexts + unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f + if isinstance(unwrapped, (Command, Group, ContextMenu)): + unwrapped.guild_only = False + allowed_contexts = unwrapped.allowed_contexts or AppCommandContext() + unwrapped.allowed_contexts = allowed_contexts else: allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext() f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment @@ -2709,9 +2728,10 @@ def guild_install(func: Optional[T] = None) -> Union[T, Callable[[T], T]]: """ def inner(f: T) -> T: - if isinstance(f, (Command, Group, ContextMenu)): - allowed_installs = f.allowed_installs or AppInstallationType() - f.allowed_installs = allowed_installs + unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f + if isinstance(unwrapped, (Command, Group, ContextMenu)): + allowed_installs = unwrapped.allowed_installs or AppInstallationType() + unwrapped.allowed_installs = allowed_installs else: allowed_installs = getattr(f, '__discord_app_commands_installation_types__', None) or AppInstallationType() f.__discord_app_commands_installation_types__ = allowed_installs # type: ignore # Runtime attribute assignment @@ -2757,9 +2777,10 @@ def user_install(func: Optional[T] = None) -> Union[T, Callable[[T], T]]: """ def inner(f: T) -> T: - if isinstance(f, (Command, Group, ContextMenu)): - allowed_installs = f.allowed_installs or AppInstallationType() - f.allowed_installs = allowed_installs + unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f + if isinstance(unwrapped, (Command, Group, ContextMenu)): + allowed_installs = unwrapped.allowed_installs or AppInstallationType() + unwrapped.allowed_installs = allowed_installs else: allowed_installs = getattr(f, '__discord_app_commands_installation_types__', None) or AppInstallationType() f.__discord_app_commands_installation_types__ = allowed_installs # type: ignore # Runtime attribute assignment @@ -2801,9 +2822,10 @@ def allowed_installs( """ def inner(f: T) -> T: - if isinstance(f, (Command, Group, ContextMenu)): - allowed_installs = f.allowed_installs or AppInstallationType() - f.allowed_installs = allowed_installs + unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f + if isinstance(unwrapped, (Command, Group, ContextMenu)): + allowed_installs = unwrapped.allowed_installs or AppInstallationType() + unwrapped.allowed_installs = allowed_installs else: allowed_installs = getattr(f, '__discord_app_commands_installation_types__', None) or AppInstallationType() f.__discord_app_commands_installation_types__ = allowed_installs # type: ignore # Runtime attribute assignment @@ -2874,8 +2896,9 @@ def default_permissions(perms_obj: Optional[Permissions] = None, /, **perms: Unp permissions = Permissions(**perms) def decorator(func: T) -> T: - if isinstance(func, (Command, Group, ContextMenu)): - func.default_permissions = permissions + unwrapped = getattr(func, '__discord_app_commands_unwrap__', func) or func + if isinstance(unwrapped, (Command, Group, ContextMenu)): + unwrapped.default_permissions = permissions else: func.__discord_app_commands_default_permissions__ = permissions # type: ignore # Runtime attribute assignment diff --git a/discord/app_commands/models.py b/discord/app_commands/models.py index b51339c26..b3a4b151e 100644 --- a/discord/app_commands/models.py +++ b/discord/app_commands/models.py @@ -597,8 +597,7 @@ class AppCommandChannel(Hashable): slowmode_delay: :class:`int` The number of seconds a member must wait between sending messages in this channel. A value of ``0`` denotes that it is disabled. - Bots and users with :attr:`~discord.Permissions.manage_channels` or - :attr:`~discord.Permissions.manage_messages` bypass slowmode. + Bots and users with :attr:`~discord.Permissions.bypass_slowmode` bypass slowmode. .. versionadded:: 2.6 nsfw: :class:`bool` @@ -779,8 +778,7 @@ class AppCommandThread(Hashable): 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. + Bots and users with :attr:`~discord.Permissions.bypass_slowmode` bypass slowmode. .. versionadded:: 2.6 message_count: :class:`int` diff --git a/discord/app_commands/transformers.py b/discord/app_commands/transformers.py index 212991cbe..3a6665634 100644 --- a/discord/app_commands/transformers.py +++ b/discord/app_commands/transformers.py @@ -23,6 +23,7 @@ DEALINGS IN THE SOFTWARE. """ from __future__ import annotations +import datetime import inspect from dataclasses import dataclass @@ -52,7 +53,7 @@ from ..channel import StageChannel, VoiceChannel, TextChannel, CategoryChannel, from ..abc import GuildChannel from ..threads import Thread from ..enums import Enum as InternalEnum, AppCommandOptionType, ChannelType, Locale -from ..utils import MISSING, maybe_coroutine, _human_join +from ..utils import MISSING, maybe_coroutine, _human_join, _iscoroutinefunction, TIMESTAMP_PATTERN from ..user import User from ..role import Role from ..member import Member @@ -62,6 +63,7 @@ from .._types import ClientT __all__ = ( 'Transformer', 'Transform', + 'Timestamp', 'Range', ) @@ -681,6 +683,41 @@ class UnionChannelTransformer(BaseChannelTransformer[ClientT]): return resolved +if TYPE_CHECKING: + Timestamp = datetime.datetime +else: + + class Timestamp(Transformer[ClientT]): + """A type annotation that can be applied to a parameter for transforming a :ddocs:`Discord style timestamp ` input to a + :class:`datetime.datetime`. + + + .. versionadded:: 2.7 + + .. warning:: + Due to a Discord limitation, no timezone is provided with the input. The UTC timezone has been supplanted instead. + + Examples + --------- + + .. code-block:: python3 + + @app_commands.command() + async def datetime(interaction: discord.Interaction, value: app_commands.Timestamp): + await interaction.response.send_message(value.isoformat()) + """ + + @property + def type(self) -> AppCommandOptionType: + return AppCommandOptionType.string + + async def transform(self, interaction: Interaction[ClientT], value: Any, /): + match = TIMESTAMP_PATTERN.match(value) + if not match: + raise TransformerError(value, AppCommandOptionType.string, self) + return datetime.datetime.fromtimestamp(int(match[1]), tz=datetime.timezone.utc) + + CHANNEL_TO_TYPES: Dict[Any, List[ChannelType]] = { AppCommandChannel: [ ChannelType.stage_voice, @@ -777,7 +814,7 @@ def get_supported_annotation( params = inspect.signature(transform_classmethod.__func__).parameters if len(params) != 3: raise TypeError('Inline transformer with transform classmethod requires 3 parameters') - if not inspect.iscoroutinefunction(transform_classmethod.__func__): + if not _iscoroutinefunction(transform_classmethod.__func__): raise TypeError('Inline transformer with transform classmethod must be a coroutine') return (InlineTransformer(annotation), MISSING, False) diff --git a/discord/app_commands/tree.py b/discord/app_commands/tree.py index aa446a01f..9350af4d6 100644 --- a/discord/app_commands/tree.py +++ b/discord/app_commands/tree.py @@ -62,7 +62,7 @@ from .installs import AppCommandContext, AppInstallationType from .translator import Translator, locale_str from ..errors import ClientException, HTTPException from ..enums import AppCommandType, InteractionType -from ..utils import MISSING, _get_as_snowflake, _is_submodule, _shorten +from ..utils import MISSING, _get_as_snowflake, _iscoroutinefunction, _is_submodule, _shorten from .._types import ClientT @@ -257,7 +257,7 @@ class CommandTree(Generic[ClientT]): -------- CommandLimitReached The maximum number of commands was reached for that guild. - This is currently 100 for slash commands and 5 for context menu commands. + This is currently 100 for slash commands and 15 for context menu commands. """ try: @@ -277,9 +277,9 @@ class CommandTree(Generic[ClientT]): counter = Counter(cmd_type for _, _, cmd_type in ctx_menu) for cmd_type, count in counter.items(): - if count > 5: + if count > 15: as_enum = AppCommandType(cmd_type) - raise CommandLimitReached(guild_id=guild.id, limit=5, type=as_enum) + raise CommandLimitReached(guild_id=guild.id, limit=15, type=as_enum) self._context_menus.update(ctx_menu) self._guild_commands[guild.id] = mapping @@ -338,7 +338,7 @@ class CommandTree(Generic[ClientT]): Or, ``guild`` and ``guilds`` were both given. CommandLimitReached The maximum number of commands was reached globally or for that guild. - This is currently 100 for slash commands and 5 for context menu commands. + This is currently 100 for slash commands and 15 for context menu commands. """ guild_ids = _retrieve_guild_ids(command, guild, guilds) @@ -361,8 +361,8 @@ class CommandTree(Generic[ClientT]): # read as `0 if override and found else 1` if confusing to_add = not (override and found) total = sum(1 for _, g, t in self._context_menus if g == guild_id and t == type) - if total + to_add > 5: - raise CommandLimitReached(guild_id=guild_id, limit=5, type=AppCommandType(type)) + if total + to_add > 15: + raise CommandLimitReached(guild_id=guild_id, limit=15, type=AppCommandType(type)) data[key] = command if guild_ids is None: @@ -839,7 +839,7 @@ class CommandTree(Generic[ClientT]): not match the signature. """ - if not inspect.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError('The error handler must be a coroutine.') params = inspect.signature(coro).parameters @@ -908,7 +908,7 @@ class CommandTree(Generic[ClientT]): """ def decorator(func: CommandCallback[Group, P, T]) -> Command[Group, P, T]: - if not inspect.iscoroutinefunction(func): + if not _iscoroutinefunction(func): raise TypeError('command function must be a coroutine function') if description is MISSING: @@ -1005,7 +1005,7 @@ class CommandTree(Generic[ClientT]): """ def decorator(func: ContextMenuCallback) -> ContextMenu: - if not inspect.iscoroutinefunction(func): + if not _iscoroutinefunction(func): raise TypeError('context menu function must be a coroutine function') actual_name = func.__name__.title() if name is MISSING else name @@ -1289,7 +1289,12 @@ class CommandTree(Generic[ClientT]): await command._invoke_autocomplete(interaction, focused, namespace) except Exception: # Suppress exception since it can't be handled anyway. - _log.exception('Ignoring exception in autocomplete for %r', command.qualified_name) + _log.exception( + 'Ignoring exception in autocomplete for %r (Guild: %s, User: %s)', + command.qualified_name, + interaction.guild_id, + interaction.user.id, + ) return diff --git a/discord/channel.py b/discord/channel.py index 3bfaeba0f..8aee381e9 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -322,8 +322,7 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): slowmode_delay: :class:`int` The number of seconds a member must wait between sending messages in this channel. A value of ``0`` denotes that it is disabled. - Bots and users with :attr:`~Permissions.manage_channels` or - :attr:`~Permissions.manage_messages` bypass slowmode. + Bots and users with :attr:`~Permissions.bypass_slowmode` bypass slowmode. nsfw: :class:`bool` If the channel is marked as "not safe for work" or "age restricted". default_auto_archive_duration: :class:`int` @@ -1516,8 +1515,7 @@ class VoiceChannel(VocalGuildChannel): slowmode_delay: :class:`int` The number of seconds a member must wait between sending messages in this channel. A value of ``0`` denotes that it is disabled. - Bots and users with :attr:`~Permissions.manage_channels` or - :attr:`~Permissions.manage_messages` bypass slowmode. + Bots and users with :attr:`~Permissions.bypass_slowmode` bypass slowmode. .. versionadded:: 2.2 """ @@ -1744,8 +1742,7 @@ class StageChannel(VocalGuildChannel): slowmode_delay: :class:`int` The number of seconds a member must wait between sending messages in this channel. A value of ``0`` denotes that it is disabled. - Bots and users with :attr:`~Permissions.manage_channels` or - :attr:`~Permissions.manage_messages` bypass slowmode. + Bots and users with :attr:`~Permissions.bypass_slowmode` bypass slowmode. .. versionadded:: 2.2 """ @@ -2409,8 +2406,7 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): slowmode_delay: :class:`int` The number of seconds a member must wait between creating threads in this forum. A value of ``0`` denotes that it is disabled. - Bots and users with :attr:`~Permissions.manage_channels` or - :attr:`~Permissions.manage_messages` bypass slowmode. + Bots and users with :attr:`~Permissions.bypass_slowmode` bypass slowmode. nsfw: :class:`bool` If the forum is marked as "not safe for work" or "age restricted". default_auto_archive_duration: :class:`int` @@ -2879,6 +2875,7 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): applied_tags: Sequence[ForumTag] = ..., view: LayoutView, suppress_embeds: bool = ..., + silent: bool = ..., reason: Optional[str] = ..., ) -> ThreadWithMessage: ... @@ -2901,6 +2898,7 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): applied_tags: Sequence[ForumTag] = ..., view: View = ..., suppress_embeds: bool = ..., + silent: bool = ..., reason: Optional[str] = ..., ) -> ThreadWithMessage: ... @@ -2922,6 +2920,7 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): applied_tags: Sequence[ForumTag] = MISSING, view: BaseView = MISSING, suppress_embeds: bool = False, + silent: bool = False, reason: Optional[str] = None, ) -> ThreadWithMessage: """|coro| @@ -2976,6 +2975,11 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): A list of stickers to upload. Must be a maximum of 3. suppress_embeds: :class:`bool` Whether to suppress embeds for the message. This sends the message without any embeds if set to ``True``. + silent: :class:`bool` + Whether to suppress push and desktop notifications for the message. This will increment the mention counter + in the UI, but will not actually send a notification. + + .. versionadded:: 2.7 reason: :class:`str` The reason for creating a new thread. Shows up on the audit log. @@ -3008,8 +3012,10 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): if view and not hasattr(view, '__discord_ui_view__'): raise TypeError(f'view parameter must be View not {view.__class__.__name__}') - if suppress_embeds: - flags = MessageFlags._from_value(4) + if suppress_embeds or silent: + flags = MessageFlags._from_value(0) + flags.suppress_embeds = suppress_embeds + flags.suppress_notifications = silent else: flags = MISSING diff --git a/discord/client.py b/discord/client.py index cfd8fb122..cc77ae5f8 100644 --- a/discord/client.py +++ b/discord/client.py @@ -68,7 +68,7 @@ from .voice_client import VoiceClient from .http import HTTPClient from .state import ConnectionState from . import utils -from .utils import MISSING, time_snowflake, deprecated +from .utils import MISSING, time_snowflake, deprecated, _iscoroutinefunction from .object import Object from .backoff import ExponentialBackoff from .webhook import Webhook @@ -88,7 +88,7 @@ if TYPE_CHECKING: from .abc import Messageable, PrivateChannel, Snowflake, SnowflakeTime from .app_commands import Command, ContextMenu from .automod import AutoModAction, AutoModRule - from .channel import DMChannel, GroupChannel + from .channel import DMChannel, GroupChannel, VoiceChannelEffect from .ext.commands import AutoShardedBot, Bot, Context, CommandError from .guild import GuildChannel from .integrations import Integration @@ -124,25 +124,25 @@ if TYPE_CHECKING: from .flags import MemberCacheFlags class _ClientOptions(TypedDict, total=False): - max_messages: int - proxy: str - proxy_auth: aiohttp.BasicAuth - shard_id: int - shard_count: int + max_messages: Optional[int] + proxy: Optional[str] + proxy_auth: Optional[aiohttp.BasicAuth] + shard_id: Optional[int] + shard_count: Optional[int] application_id: int member_cache_flags: MemberCacheFlags chunk_guilds_at_startup: bool - status: Status - activity: BaseActivity - allowed_mentions: AllowedMentions + status: Optional[Status] + activity: Optional[BaseActivity] + allowed_mentions: Optional[AllowedMentions] heartbeat_timeout: float guild_ready_timeout: float assume_unsync_clock: bool enable_debug_events: bool enable_raw_presences: bool http_trace: aiohttp.TraceConfig - max_ratelimit_timeout: float - connector: aiohttp.BaseConnector + max_ratelimit_timeout: Optional[float] + connector: Optional[aiohttp.BaseConnector] # fmt: off @@ -340,6 +340,10 @@ class Client: VoiceClient.warn_nacl = False _log.warning('PyNaCl is not installed, voice will NOT be supported') + if VoiceClient.warn_dave: + VoiceClient.warn_dave = False + _log.warning('davey is not installed, voice will NOT be supported') + async def __aenter__(self) -> Self: await self._async_setup_hook() return self @@ -1753,6 +1757,38 @@ class Client: timeout: Optional[float] = ..., ) -> Tuple[ScheduledEvent, User]: ... + @overload + async def wait_for( + self, + event: Literal['scheduled_event_update'], + /, + *, + check: Optional[Callable[[ScheduledEvent, ScheduledEvent], bool]] = ..., + timeout: Optional[float] = ..., + ) -> Tuple[ScheduledEvent, ScheduledEvent]: ... + + # Soundboard + + @overload + async def wait_for( + self, + event: Literal['soundboard_sound_create', 'soundboard_sound_delete'], + /, + *, + check: Optional[Callable[[SoundboardSound], bool]] = ..., + timeout: Optional[float] = ..., + ) -> SoundboardSound: ... + + @overload + async def wait_for( + self, + event: Literal['soundboard_sound_update'], + /, + *, + check: Optional[Callable[[SoundboardSound, SoundboardSound], bool]] = ..., + timeout: Optional[float] = ..., + ) -> Tuple[SoundboardSound, SoundboardSound]: ... + # Stages @overload @@ -1859,6 +1895,16 @@ class Client: timeout: Optional[float] = ..., ) -> Tuple[Member, VoiceState, VoiceState]: ... + @overload + async def wait_for( + self, + event: Literal['voice_channel_effect'], + /, + *, + check: Optional[Callable[[VoiceChannelEffect], bool]] = ..., + timeout: Optional[float] = ..., + ) -> VoiceChannelEffect: ... + # Polls @overload @@ -2052,7 +2098,7 @@ class Client: The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError('event registered must be a coroutine function') setattr(self, coro.__name__, coro) @@ -2511,7 +2557,7 @@ class Client: ) return Invite.from_incomplete(state=self._connection, data=data) - async def delete_invite(self, invite: Union[Invite, str], /) -> Invite: + async def delete_invite(self, invite: Union[Invite, str], /, *, reason: Optional[str] = None) -> Invite: """|coro| Revokes an :class:`.Invite`, URL, or ID to an invite. @@ -2527,6 +2573,8 @@ class Client: ---------- invite: Union[:class:`.Invite`, :class:`str`] The invite to revoke. + reason: Optional[:class:`str`] + The reason for deleting the invite. Shows up on the audit log. Raises ------- @@ -2539,7 +2587,7 @@ class Client: """ resolved = utils.resolve_invite(invite) - data = await self.http.delete_invite(resolved.code) + data = await self.http.delete_invite(resolved.code, reason=reason) return Invite.from_incomplete(state=self._connection, data=data) # Miscellaneous stuff diff --git a/discord/components.py b/discord/components.py index 08ae4f277..9536e93a3 100644 --- a/discord/components.py +++ b/discord/components.py @@ -72,6 +72,12 @@ if TYPE_CHECKING: ContainerComponent as ContainerComponentPayload, UnfurledMediaItem as UnfurledMediaItemPayload, LabelComponent as LabelComponentPayload, + FileUploadComponent as FileUploadComponentPayload, + RadioGroupComponent as RadioGroupComponentPayload, + RadioGroupOption as RadioGroupOptionPayload, + CheckboxGroupComponent as CheckboxGroupComponentPayload, + CheckboxGroupOption as CheckboxGroupOptionPayload, + CheckboxComponent as CheckboxComponentPayload, ) from .emoji import Emoji @@ -91,6 +97,7 @@ if TYPE_CHECKING: 'SectionComponent', 'Component', ] + OptionPayload = Union[SelectOptionPayload, RadioGroupOptionPayload, CheckboxGroupOptionPayload] __all__ = ( @@ -112,6 +119,12 @@ __all__ = ( 'TextDisplay', 'SeparatorComponent', 'LabelComponent', + 'FileUploadComponent', + 'RadioGroupComponent', + 'CheckboxGroupComponent', + 'CheckboxComponent', + 'RadioGroupOption', + 'CheckboxGroupOption', ) @@ -131,6 +144,8 @@ class Component: - :class:`FileComponent` - :class:`SeparatorComponent` - :class:`Container` + - :class:`LabelComponent` + - :class:`FileUploadComponent` This class is abstract and cannot be instantiated. @@ -166,6 +181,71 @@ class Component: raise NotImplementedError +class BaseOption: + """Represents a base option for components that have options. + + This currently implements: + + - :class:`SelectOption` + - :class:`RadioGroupOption` + - :class:`CheckboxGroupOption` + + .. versionadded:: 2.7 + """ + + __slots__: Tuple[str, ...] = ('label', 'value', 'description', 'default') + + __repr_info__: ClassVar[Tuple[str, ...]] = ('label', 'value', 'description', 'default') + + def __init__( + self, + *, + label: str, + value: str = MISSING, + description: Optional[str] = None, + default: bool = False, + ) -> None: + self.label: str = label + self.value: str = label if value is MISSING else value + self.description: Optional[str] = description + self.default: bool = default + + def __repr__(self) -> str: + attrs = ' '.join(f'{key}={getattr(self, key)!r}' for key in self.__repr_info__) + return f'<{self.__class__.__name__} {attrs}>' + + def __str__(self) -> str: + base = self.label + + if self.description: + return f'{base}\n{self.description}' + return base + + @classmethod + def from_dict(cls, data: OptionPayload) -> Self: + return cls( + label=data['label'], + value=data['value'], + description=data.get('description'), + default=data.get('default', False), + ) + + def to_dict(self) -> OptionPayload: + payload: OptionPayload = { + 'label': self.label, + 'value': self.value, + 'default': self.default, + } + + if self.description: + payload['description'] = self.description + + return payload + + def copy(self) -> Self: + return self.__class__.from_dict(self.to_dict()) + + class ActionRow(Component): """Represents a Discord Bot UI Kit Action Row. @@ -412,7 +492,7 @@ class SelectMenu(Component): return payload -class SelectOption: +class SelectOption(BaseOption): """Represents a select menu's option. These can be created by users. @@ -450,13 +530,8 @@ class SelectOption: Whether this option is selected by default. """ - __slots__: Tuple[str, ...] = ( - 'label', - 'value', - 'description', - '_emoji', - 'default', - ) + __slots__: Tuple[str, ...] = BaseOption.__slots__ + ('_emoji',) + __repr_info__ = BaseOption.__repr_info__ + ('emoji',) def __init__( self, @@ -467,18 +542,9 @@ class SelectOption: emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, default: bool = False, ) -> None: - self.label: str = label - self.value: str = label if value is MISSING else value - self.description: Optional[str] = description + super().__init__(label=label, value=value, description=description, default=default) self.emoji = emoji - self.default: bool = default - - def __repr__(self) -> str: - return ( - f'' - ) def __str__(self) -> str: if self.emoji: @@ -508,7 +574,7 @@ class SelectOption: self._emoji = None @classmethod - def from_dict(cls, data: SelectOptionPayload) -> SelectOption: + def from_dict(cls, data: SelectOptionPayload) -> Self: try: emoji = PartialEmoji.from_dict(data['emoji']) # pyright: ignore[reportTypedDictNotRequiredAccess] except KeyError: @@ -518,28 +584,18 @@ class SelectOption: label=data['label'], value=data['value'], description=data.get('description'), - emoji=emoji, default=data.get('default', False), + emoji=emoji, ) def to_dict(self) -> SelectOptionPayload: - payload: SelectOptionPayload = { - 'label': self.label, - 'value': self.value, - 'default': self.default, - } + payload: SelectOptionPayload = super().to_dict() # type: ignore if self.emoji: payload['emoji'] = self.emoji.to_dict() - if self.description: - payload['description'] = self.description - return payload - def copy(self) -> SelectOption: - return self.__class__.from_dict(self.to_dict()) - class TextInput(Component): """Represents a text input from the Discord Bot UI Kit. @@ -1384,6 +1440,313 @@ class LabelComponent(Component): return payload +class FileUploadComponent(Component): + """Represents a file upload component from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type for creating a file upload is + :class:`discord.ui.FileUpload` not this one. + + .. versionadded:: 2.7 + + Attributes + ------------ + custom_id: Optional[:class:`str`] + The ID of the component that gets received during an interaction. + min_values: :class:`int` + The minimum number of files that must be uploaded for this component. + Defaults to 1 and must be between 0 and 10. + max_values: :class:`int` + The maximum number of files that must be uploaded for this component. + Defaults to 1 and must be between 1 and 10. + id: Optional[:class:`int`] + The ID of this component. + required: :class:`bool` + Whether the component is required. + Defaults to ``True``. + """ + + __slots__: Tuple[str, ...] = ( + 'custom_id', + 'min_values', + 'max_values', + 'required', + 'id', + ) + + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + + def __init__(self, data: FileUploadComponentPayload, /) -> None: + self.custom_id: str = data['custom_id'] + self.min_values: int = data.get('min_values', 1) + self.max_values: int = data.get('max_values', 1) + self.required: bool = data.get('required', True) + self.id: Optional[int] = data.get('id') + + @property + def type(self) -> Literal[ComponentType.file_upload]: + """:class:`ComponentType`: The type of component.""" + return ComponentType.file_upload + + def to_dict(self) -> FileUploadComponentPayload: + payload: FileUploadComponentPayload = { + 'type': self.type.value, + 'custom_id': self.custom_id, + 'min_values': self.min_values, + 'max_values': self.max_values, + 'required': self.required, + } + if self.id is not None: + payload['id'] = self.id + + return payload + + +class RadioGroupComponent(Component): + """Represents a radio group component from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type for creating a radio group is + :class:`discord.ui.RadioGroup` not this one. + + .. versionadded:: 2.7 + + Attributes + ------------ + custom_id: Optional[:class:`str`] + The ID of the component that gets received during an interaction. + id: Optional[:class:`int`] + The ID of this component. + required: :class:`bool` + Whether the component is required. + Defaults to ``True``. + options: List[:class:`RadioGroupOption`] + A list of options that can be selected in this group. + """ + + __slots__: Tuple[str, ...] = ('custom_id', 'required', 'id', 'options') + + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + + def __init__(self, data: RadioGroupComponentPayload, /) -> None: + self.custom_id: str = data['custom_id'] + self.required: bool = data.get('required', True) + self.id: Optional[int] = data.get('id') + self.options: List[RadioGroupOption] = [RadioGroupOption.from_dict(option) for option in data.get('options', [])] + + @property + def type(self) -> Literal[ComponentType.radio_group]: + """:class:`ComponentType`: The type of component.""" + return ComponentType.radio_group + + def to_dict(self) -> RadioGroupComponentPayload: + payload: RadioGroupComponentPayload = { + 'type': self.type.value, + 'custom_id': self.custom_id, + 'required': self.required, + } + if self.id is not None: + payload['id'] = self.id + if self.options: + payload['options'] = [option.to_dict() for option in self.options] + + return payload + + +class RadioGroupOption(BaseOption): + """Represents a radio group's option + + These can be created by users. + + .. versionadded:: 2.7 + + Parameters + ----------- + label: :class:`str` + The label of the option. This is displayed to users. + Can only be up to 100 characters. + value: :class:`str` + The value of the option. This is not displayed to users. + If not provided when constructed then it defaults to the label. + Can only be up to 100 characters. + description: Optional[:class:`str`] + An additional description of the option, if any. + Can only be up to 100 characters. + default: :class:`bool` + Whether this option is selected by default. + + Attributes + ----------- + label: :class:`str` + The label of the option. This is displayed to users. + value: :class:`str` + The value of the option. This is not displayed to users. + If not provided when constructed then it defaults to the + label. + description: Optional[:class:`str`] + An additional description of the option, if any. + default: :class:`bool` + Whether this option is selected by default. + """ + + +class CheckboxGroupComponent(Component): + """Represents a checkbox group component from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type for creating a checkbox group is + :class:`discord.ui.CheckboxGroup` not this one. + + .. versionadded:: 2.7 + + Attributes + ------------ + custom_id: Optional[:class:`str`] + The ID of the component that gets received during an interaction. + id: Optional[:class:`int`] + The ID of this component. + required: :class:`bool` + Whether the component is required. + Defaults to ``True``. + min_values: :class:`int` + The minimum number of options that must be selected in this component. + Must be between 0 and 10. Defaults to 0. + max_values: :class:`int` + The maximum number of options that can be selected in this component. + Must be between 1 and 10. Defaults to 1. + options: List[:class:`CheckboxGroupOption`] + A list of options that can be selected in this group. + """ + + __slots__: Tuple[str, ...] = ('custom_id', 'required', 'id', 'min_values', 'max_values', 'options') + + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + + def __init__(self, data: CheckboxGroupComponentPayload, /) -> None: + self.custom_id: str = data['custom_id'] + self.required: bool = data.get('required', True) + self.id: Optional[int] = data.get('id') + self.min_values: int = data.get('min_values', 0) + self.max_values: int = data.get('max_values', 1) + self.options: List[CheckboxGroupOption] = [ + CheckboxGroupOption.from_dict(option) for option in data.get('options', []) + ] + + @property + def type(self) -> Literal[ComponentType.checkbox_group]: + """:class:`ComponentType`: The type of component.""" + return ComponentType.checkbox_group + + def to_dict(self) -> CheckboxGroupComponentPayload: + payload: CheckboxGroupComponentPayload = { + 'type': self.type.value, + 'custom_id': self.custom_id, + 'min_values': self.min_values, + 'max_values': self.max_values, + 'required': self.required, + } + if self.id is not None: + payload['id'] = self.id + if self.options: + payload['options'] = [option.to_dict() for option in self.options] + + return payload + + +class CheckboxGroupOption(BaseOption): + """Represents a checkbox group's option + + These can be created by users. + + .. versionadded:: 2.7 + + Parameters + ----------- + label: :class:`str` + The label of the option. This is displayed to users. + Can only be up to 100 characters. + value: :class:`str` + The value of the option. This is not displayed to users. + If not provided when constructed then it defaults to the label. + Can only be up to 100 characters. + description: Optional[:class:`str`] + An additional description of the option, if any. + Can only be up to 100 characters. + default: :class:`bool` + Whether this option is selected by default. + + Attributes + ----------- + label: :class:`str` + The label of the option. This is displayed to users. + value: :class:`str` + The value of the option. This is not displayed to users. + If not provided when constructed then it defaults to the + label. + description: Optional[:class:`str`] + An additional description of the option, if any. + default: :class:`bool` + Whether this option is selected by default. + """ + + +class CheckboxComponent(Component): + """Represents a checkbox component from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type for creating a checkbox is + :class:`discord.ui.Checkbox` not this one. + + .. versionadded:: 2.7 + + Attributes + ------------ + custom_id: Optional[:class:`str`] + The ID of the component that gets received during an interaction. + id: Optional[:class:`int`] + The ID of this component. + default: :class:`bool` + Whether this checkbox is selected by default. + """ + + __slots__: Tuple[str, ...] = ('custom_id', 'default', 'id') + + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + + def __init__(self, data: CheckboxComponentPayload, /) -> None: + self.custom_id: str = data['custom_id'] + self.id: Optional[int] = data.get('id') + self.default: bool = data.get('default', False) + + @property + def type(self) -> Literal[ComponentType.checkbox]: + """:class:`ComponentType`: The type of component.""" + return ComponentType.checkbox + + def to_dict(self) -> CheckboxComponentPayload: + payload: CheckboxComponentPayload = { + 'type': self.type.value, + 'custom_id': self.custom_id, + 'default': self.default, + } + if self.id is not None: + payload['id'] = self.id + + return payload + + def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] = None) -> Optional[Component]: if data['type'] == 1: return ActionRow(data) @@ -1409,3 +1772,11 @@ def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] return Container(data, state) elif data['type'] == 18: return LabelComponent(data, state) + elif data['type'] == 19: + return FileUploadComponent(data) + elif data['type'] == 21: + return RadioGroupComponent(data) + elif data['type'] == 22: + return CheckboxGroupComponent(data) + elif data['type'] == 23: + return CheckboxComponent(data) diff --git a/discord/emoji.py b/discord/emoji.py index 74f344acc..efea38c75 100644 --- a/discord/emoji.py +++ b/discord/emoji.py @@ -165,8 +165,8 @@ class Emoji(_EmojiTag, AssetMixin): @property def url(self) -> str: """:class:`str`: Returns the URL of the emoji.""" - fmt = 'gif' if self.animated else 'png' - return f'{Asset.BASE}/emojis/{self.id}.{fmt}' + end = 'webp?animated=true' if self.animated else 'png' + return f'{Asset.BASE}/emojis/{self.id}.{end}' @property def roles(self) -> List[Role]: diff --git a/discord/enums.py b/discord/enums.py index 172f736a9..025b54cb4 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -75,6 +75,8 @@ __all__ = ( 'EntitlementType', 'EntitlementOwnerType', 'PollLayoutType', + 'InviteType', + 'ReactionType', 'VoiceChannelEffectAnimationType', 'SubscriptionStatus', 'MessageReferenceType', @@ -277,6 +279,16 @@ class MessageType(Enum): poll_result = 46 emoji_added = 63 + def is_deletable(self) -> bool: + return self not in { + MessageType.recipient_add, + MessageType.recipient_remove, + MessageType.call, + MessageType.channel_name_change, + MessageType.channel_icon_change, + MessageType.thread_starter_message, + } + class SpeakingState(Enum): none = 0 @@ -681,6 +693,11 @@ class ComponentType(Enum): separator = 14 container = 17 label = 18 + file_upload = 19 + # checkpoint = 20 + radio_group = 21 + checkbox_group = 22 + checkbox = 23 def __int__(self) -> int: return self.value diff --git a/discord/errors.py b/discord/errors.py index c07a7ed15..11f5cfaa2 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -48,6 +48,7 @@ __all__ = ( 'PrivilegedIntentsRequired', 'InteractionResponded', 'MissingApplicationID', + 'FFmpegProcessError', ) APP_ID_NOT_FOUND = ( @@ -74,6 +75,15 @@ class ClientException(DiscordException): pass +class FFmpegProcessError(ClientException): + """Exception that's raised when an FFmpeg process fails. + + .. versionadded:: 2.7 + """ + + pass + + class GatewayNotFound(DiscordException): """An exception that is raised when the gateway for Discord could not be found""" diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 3a916d69e..46b5f5850 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -25,7 +25,6 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -import asyncio import collections import collections.abc import inspect @@ -53,7 +52,7 @@ from typing import ( import discord from discord import app_commands from discord.app_commands.tree import _retrieve_guild_ids -from discord.utils import MISSING, _is_submodule +from discord.utils import MISSING, _iscoroutinefunction, _is_submodule from .core import GroupMixin from .view import StringView @@ -89,8 +88,8 @@ if TYPE_CHECKING: PrefixType = Union[_Prefix, _PrefixCallable[BotT]] class _BotOptions(_ClientOptions, total=False): - owner_id: int - owner_ids: Collection[int] + owner_id: Optional[int] + owner_ids: Optional[Collection[int]] strip_after_prefix: bool case_insensitive: bool @@ -581,7 +580,7 @@ class BotBase(GroupMixin[None]): TypeError The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError('The pre-invoke hook must be a coroutine.') self._before_invoke = coro @@ -618,7 +617,7 @@ class BotBase(GroupMixin[None]): TypeError The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError('The post-invoke hook must be a coroutine.') self._after_invoke = coro @@ -654,7 +653,7 @@ class BotBase(GroupMixin[None]): """ name = func.__name__ if name is MISSING else name - if not asyncio.iscoroutinefunction(func): + if not _iscoroutinefunction(func): raise TypeError('Listeners must be coroutines') if name in self.extra_events: diff --git a/discord/ext/commands/cog.py b/discord/ext/commands/cog.py index 371a9f8c1..7f553c42d 100644 --- a/discord/ext/commands/cog.py +++ b/discord/ext/commands/cog.py @@ -28,7 +28,7 @@ import inspect import discord import logging from discord import app_commands -from discord.utils import maybe_coroutine, _to_kebab_case +from discord.utils import maybe_coroutine, _iscoroutinefunction, _to_kebab_case from typing import ( Any, @@ -45,29 +45,18 @@ from typing import ( Tuple, TypeVar, Union, - TypedDict, ) -from ._types import _BaseCommand, BotT +from ._types import _BaseCommand, BotT, MaybeCoro if TYPE_CHECKING: - from typing_extensions import Self, Unpack + from typing_extensions import Self from discord.abc import Snowflake from discord._types import ClientT from .bot import BotBase from .context import Context - from .core import Command, _CommandDecoratorKwargs - - class _CogKwargs(TypedDict, total=False): - name: str - group_name: Union[str, app_commands.locale_str] - description: str - group_description: Union[str, app_commands.locale_str] - group_nsfw: bool - group_auto_locale_strings: bool - group_extras: Dict[Any, Any] - command_attrs: _CommandDecoratorKwargs + from .core import Command __all__ = ( @@ -182,7 +171,7 @@ 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: Unpack[_CogKwargs]) -> CogMeta: + def __new__(cls, *args: Any, **kwargs: Any) -> CogMeta: name, bases, attrs = args if any(issubclass(base, app_commands.Group) for base in bases): raise TypeError( @@ -244,7 +233,7 @@ class CogMeta(type): if elem.startswith(('cog_', 'bot_')): raise TypeError(no_bot_cog.format(base, elem)) cog_app_commands[elem] = value - elif inspect.iscoroutinefunction(value): + elif _iscoroutinefunction(value): try: getattr(value, '__cog_listener__') except AttributeError: @@ -533,7 +522,7 @@ class Cog(metaclass=CogMeta): actual = func if isinstance(actual, staticmethod): actual = actual.__func__ - if not inspect.iscoroutinefunction(actual): + if not _iscoroutinefunction(actual): raise TypeError('Listener function must be a coroutine function.') actual.__cog_listener__ = True to_assign = name or actual.__name__ @@ -594,7 +583,7 @@ class Cog(metaclass=CogMeta): pass @_cog_special_method - def bot_check_once(self, ctx: Context[BotT]) -> bool: + def bot_check_once(self, ctx: Context[BotT]) -> MaybeCoro[bool]: """A special method that registers as a :meth:`.Bot.check_once` check. @@ -604,7 +593,7 @@ class Cog(metaclass=CogMeta): return True @_cog_special_method - def bot_check(self, ctx: Context[BotT]) -> bool: + def bot_check(self, ctx: Context[BotT]) -> MaybeCoro[bool]: """A special method that registers as a :meth:`.Bot.check` check. @@ -614,7 +603,7 @@ class Cog(metaclass=CogMeta): return True @_cog_special_method - def cog_check(self, ctx: Context[BotT]) -> bool: + def cog_check(self, ctx: Context[BotT]) -> MaybeCoro[bool]: """A special method that registers as a :func:`~discord.ext.commands.check` for every command and subcommand in this cog. @@ -624,7 +613,7 @@ class Cog(metaclass=CogMeta): return True @_cog_special_method - def interaction_check(self, interaction: discord.Interaction[ClientT], /) -> bool: + def interaction_check(self, interaction: discord.Interaction[ClientT], /) -> MaybeCoro[bool]: """A special method that registers as a :func:`discord.app_commands.check` for every app command and subcommand in this cog. @@ -657,7 +646,9 @@ class Cog(metaclass=CogMeta): pass @_cog_special_method - async def cog_app_command_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError) -> None: + async def cog_app_command_error( + self, interaction: discord.Interaction[ClientT], error: app_commands.AppCommandError + ) -> None: """|coro| A special method that is called whenever an error within diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index 968fec419..54b3dd973 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -259,6 +259,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): bot: BotT = interaction.client data: ApplicationCommandInteractionData = interaction.data # type: ignore + type_ = data.get('type', 1) if interaction.message is None: synthetic_payload = { 'id': interaction.id, @@ -268,7 +269,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): 'tts': False, 'pinned': False, 'edited_timestamp': None, - 'type': MessageType.chat_input_command if data.get('type', 1) == 1 else MessageType.context_menu_command, + 'type': MessageType.chat_input_command.value if type_ == 1 else MessageType.context_menu_command.value, 'flags': 64, 'content': '', 'mentions': [], @@ -288,7 +289,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): else: message = interaction.message - prefix = '/' if data.get('type', 1) == 1 else '\u200b' # Mock the prefix + prefix = '/' if type_ == 1 else '\u200b' # Mock the prefix ctx = cls( message=message, bot=bot, diff --git a/discord/ext/commands/converter.py b/discord/ext/commands/converter.py index baf22c626..a4b9b3b7d 100644 --- a/discord/ext/commands/converter.py +++ b/discord/ext/commands/converter.py @@ -24,6 +24,7 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations +import datetime import inspect import re from typing import ( @@ -86,6 +87,7 @@ __all__ = ( 'clean_content', 'Greedy', 'Range', + 'Timestamp', 'run_converters', ) @@ -893,6 +895,28 @@ class GuildStickerConverter(IDConverter[discord.GuildSticker]): return result +if TYPE_CHECKING: + Timestamp = datetime.datetime +else: + + class Timestamp(Converter[str]): + """Converts to a :class:`datetime.datetime`. + + Conversion is attempted based on the :ddocs:`Discord style timestamp ` input format. + + .. versionadded:: 2.7 + + .. warning:: + Due to a Discord limitation, no timezone is provided with the input. The UTC timezone has been supplanted instead. + """ + + async def convert(self, ctx: Context[BotT], argument: str) -> datetime.datetime: + match = discord.utils.TIMESTAMP_PATTERN.match(argument) + if not match: + raise BadTimestampArgument(argument) + return datetime.datetime.fromtimestamp(int(match[1]), tz=datetime.timezone.utc) + + class ScheduledEventConverter(IDConverter[discord.ScheduledEvent]): """Converts to a :class:`~discord.ScheduledEvent`. diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index 9ec0dd484..4adaf1fb1 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -68,11 +68,11 @@ if TYPE_CHECKING: class _CommandDecoratorKwargs(TypedDict, total=False): enabled: bool - help: str - brief: str - usage: str + help: Optional[str] + brief: Optional[str] + usage: Optional[str] rest_is_raw: bool - aliases: List[str] + aliases: Union[List[str], Tuple[str, ...]] description: str hidden: bool checks: List[UserCheck[Context[Any]]] @@ -427,7 +427,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): /, **kwargs: Unpack[_CommandKwargs], ) -> None: - if not asyncio.iscoroutinefunction(func): + if not discord.utils._iscoroutinefunction(func): raise TypeError('Callback must be a coroutine.') name = kwargs.get('name') or func.__name__ @@ -449,7 +449,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): self.brief: Optional[str] = kwargs.get('brief') self.usage: Optional[str] = kwargs.get('usage') self.rest_is_raw: bool = kwargs.get('rest_is_raw', False) - self.aliases: Union[List[str], Tuple[str]] = kwargs.get('aliases', []) + self.aliases: Union[List[str], Tuple[str, ...]] = kwargs.get('aliases', []) self.extras: Dict[Any, Any] = kwargs.get('extras', {}) if not isinstance(self.aliases, (list, tuple)): @@ -1102,7 +1102,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not discord.utils._iscoroutinefunction(coro): raise TypeError('The error handler must be a coroutine.') self.on_error: Error[CogT, Any] = coro @@ -1140,7 +1140,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): TypeError The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not discord.utils._iscoroutinefunction(coro): raise TypeError('The pre-invoke hook must be a coroutine.') self._before_invoke = coro @@ -1171,7 +1171,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): TypeError The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not discord.utils._iscoroutinefunction(coro): raise TypeError('The post-invoke hook must be a coroutine.') self._after_invoke = coro @@ -1945,7 +1945,7 @@ def check(predicate: UserCheck[ContextT], /) -> Check[ContextT]: return func - if inspect.iscoroutinefunction(predicate): + if discord.utils._iscoroutinefunction(predicate): decorator.predicate = predicate else: @@ -2369,7 +2369,7 @@ def guild_only() -> Check[Any]: return func - if inspect.iscoroutinefunction(predicate): + if discord.utils._iscoroutinefunction(predicate): decorator.predicate = predicate else: @@ -2444,7 +2444,7 @@ def is_nsfw() -> Check[Any]: return func - if inspect.iscoroutinefunction(predicate): + if discord.utils._iscoroutinefunction(predicate): decorator.predicate = predicate else: diff --git a/discord/ext/commands/errors.py b/discord/ext/commands/errors.py index a962a4e73..3c8b60181 100644 --- a/discord/ext/commands/errors.py +++ b/discord/ext/commands/errors.py @@ -79,6 +79,7 @@ __all__ = ( 'SoundboardSoundNotFound', 'PartialEmojiConversionFailure', 'BadBoolArgument', + 'BadTimestampArgument', 'MissingRole', 'BotMissingRole', 'MissingAnyRole', @@ -602,6 +603,24 @@ class BadBoolArgument(BadArgument): super().__init__(f'{argument} is not a recognised boolean option') +class BadTimestampArgument(BadArgument): + """Exception raised when a timestamp argument was not convertable. + + This inherits from :exc:`BadArgument` + + .. versionadded:: 2.7 + + Attributes + ----------- + argument: :class:`str` + The datetime/timestamp argument supplied by the caller that was not a valid timestamp format. + """ + + def __init__(self, argument: str) -> None: + self.argument: str = argument + super().__init__(f'{argument} is not a recognised datetime or timestamp option') + + class RangeError(BadArgument): """Exception raised when an argument is out of range. @@ -870,7 +889,7 @@ class BotMissingPermissions(CheckFailure): class BadUnionArgument(UserInputError): - """Exception raised when a :data:`typing.Union` converter fails for all + """Exception raised when a :obj:`typing.Union` converter fails for all its associated types. This inherits from :exc:`UserInputError` diff --git a/discord/ext/commands/flags.py b/discord/ext/commands/flags.py index 0b03b81d4..10d036f8a 100644 --- a/discord/ext/commands/flags.py +++ b/discord/ext/commands/flags.py @@ -50,6 +50,25 @@ if TYPE_CHECKING: from .context import Context from .parameters import Parameter +try: + from annotationlib import call_annotate_function, get_annotate_from_class_namespace # type: ignore + + def get_annotations_from_namespace(namespace: Dict[str, Any]) -> Dict[str, Any]: + # In Python 3.14, classes no longer get `__annotations__` and instead a function + # under __annotate__ is used instead that that takes a format argument on how to + # receive those annotations. + # Format 1 is full value, Format 3 is value and ForwardRef for undefined ones + # So format 3 is the one we're typically used to + annotate = get_annotate_from_class_namespace(namespace) + if annotate is not None: + return call_annotate_function(annotate, 3) # type: ignore + return namespace.get('__annotations__', {}) + +except ImportError: + + def get_annotations_from_namespace(namespace: Dict[str, Any]) -> Dict[str, Any]: + return namespace.get('__annotations__', {}) + @dataclass class Flag: @@ -177,7 +196,7 @@ def validate_flag_name(name: str, forbidden: Set[str]) -> None: def get_flags(namespace: Dict[str, Any], globals: Dict[str, Any], locals: Dict[str, Any]) -> Dict[str, Flag]: - annotations = namespace.get('__annotations__', {}) + annotations = get_annotations_from_namespace(namespace) case_insensitive = namespace['__commands_flag_case_insensitive__'] flags: Dict[str, Flag] = {} cache: Dict[str, Any] = {} diff --git a/discord/ext/commands/help.py b/discord/ext/commands/help.py index dabbd9ef9..10648b4cc 100644 --- a/discord/ext/commands/help.py +++ b/discord/ext/commands/help.py @@ -69,12 +69,12 @@ if TYPE_CHECKING: class _HelpCommandOptions(TypedDict, total=False): show_hidden: bool - verify_checks: bool + verify_checks: Optional[bool] command_attrs: _CommandKwargs class _BaseHelpCommandOptions(_HelpCommandOptions, total=False): sort_commands: bool - dm_help: bool + dm_help: Optional[bool] dm_help_threshold: int no_category: str paginator: Paginator @@ -394,7 +394,7 @@ class HelpCommand: def __init__(self, **options: Unpack[_HelpCommandOptions]) -> None: self.show_hidden: bool = options.pop('show_hidden', False) - self.verify_checks: bool = options.pop('verify_checks', True) + self.verify_checks: Optional[bool] = options.pop('verify_checks', True) self.command_attrs = attrs = options.pop('command_attrs', {}) attrs.setdefault('name', 'help') attrs.setdefault('help', 'Shows this message') @@ -1070,7 +1070,7 @@ class DefaultHelpCommand(HelpCommand): self.width: int = options.pop('width', 80) self.indent: int = options.pop('indent', 2) self.sort_commands: bool = options.pop('sort_commands', True) - self.dm_help: bool = options.pop('dm_help', False) + self.dm_help: Optional[bool] = options.pop('dm_help', False) self.dm_help_threshold: int = options.pop('dm_help_threshold', 1000) self.arguments_heading: str = options.pop('arguments_heading', 'Arguments:') self.commands_heading: str = options.pop('commands_heading', 'Commands:') @@ -1364,7 +1364,7 @@ class MinimalHelpCommand(HelpCommand): def __init__(self, **options: Unpack[_MinimalHelpCommandOptions]) -> None: self.sort_commands: bool = options.pop('sort_commands', True) self.commands_heading: str = options.pop('commands_heading', 'Commands') - self.dm_help: bool = options.pop('dm_help', False) + self.dm_help: Optional[bool] = options.pop('dm_help', False) self.dm_help_threshold: int = options.pop('dm_help_threshold', 1000) self.aliases_heading: str = options.pop('aliases_heading', 'Aliases:') self.no_category: str = options.pop('no_category', 'No Category') diff --git a/discord/ext/commands/hybrid.py b/discord/ext/commands/hybrid.py index 70d18f5d1..e7366c6e8 100644 --- a/discord/ext/commands/hybrid.py +++ b/discord/ext/commands/hybrid.py @@ -67,10 +67,12 @@ if TYPE_CHECKING: default_permissions: bool nsfw: bool description: str + case_insensitive: bool class _HybridGroupDecoratorKwargs(_HybridGroupKwargs, total=False): description: Union[str, app_commands.locale_str] - fallback: Union[str, app_commands.locale_str] + fallback: Optional[str] + fallback_locale: Optional[app_commands.locale_str] __all__ = ( @@ -532,6 +534,10 @@ class HybridCommand(Command[CogT, P, T]): HybridAppCommand(self) if self.with_app_command else None ) + @property + def __discord_app_commands_unwrap__(self) -> Optional[HybridAppCommand[CogT, Any, T]]: + return self.app_command + @property def cog(self) -> CogT: return self._cog @@ -700,6 +706,10 @@ class HybridGroup(Group[CogT, P, T]): return None return self.app_command.get_command(self.fallback) # type: ignore + @property + def __discord_app_commands_unwrap__(self) -> Optional[app_commands.Group]: + return self.app_command + @property def cog(self) -> CogT: return self._cog diff --git a/discord/ext/tasks/__init__.py b/discord/ext/tasks/__init__.py index 6ed0273e3..e3d9c33e7 100644 --- a/discord/ext/tasks/__init__.py +++ b/discord/ext/tasks/__init__.py @@ -37,6 +37,7 @@ from typing import ( Type, TypeVar, Union, + overload, ) import aiohttp @@ -45,7 +46,7 @@ import inspect from collections.abc import Sequence from discord.backoff import ExponentialBackoff -from discord.utils import MISSING +from discord.utils import MISSING, _iscoroutinefunction _log = logging.getLogger(__name__) @@ -176,12 +177,12 @@ class Loop(Generic[LF]): if self.count is not None and self.count <= 0: raise ValueError('count must be greater than 0 or None.') - self.change_interval(seconds=seconds, minutes=minutes, hours=hours, time=time) + self.change_interval(seconds=seconds, minutes=minutes, hours=hours, time=time) # type: ignore self._last_iteration_failed = False self._last_iteration: datetime.datetime = MISSING self._next_iteration = None - if not inspect.iscoroutinefunction(self.coro): + if not _iscoroutinefunction(self.coro): raise TypeError(f'Expected coroutine function, not {type(self.coro).__name__!r}.') async def _call_loop_function(self, name: str, *args: Any, **kwargs: Any) -> None: @@ -573,7 +574,7 @@ class Loop(Generic[LF]): The function was not a coroutine. """ - if not inspect.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError(f'Expected coroutine function, received {coro.__class__.__name__}.') self._before_loop = coro @@ -601,7 +602,7 @@ class Loop(Generic[LF]): The function was not a coroutine. """ - if not inspect.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError(f'Expected coroutine function, received {coro.__class__.__name__}.') self._after_loop = coro @@ -631,7 +632,7 @@ class Loop(Generic[LF]): TypeError The function was not a coroutine. """ - if not inspect.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError(f'Expected coroutine function, received {coro.__class__.__name__}.') self._error = coro # type: ignore @@ -710,6 +711,22 @@ class Loop(Generic[LF]): ret = sorted(set(ret)) # de-dupe and sort times return ret + @overload + def change_interval( + self, + *, + seconds: float = 0, + minutes: float = 0, + hours: float = 0, + ) -> None: ... + + @overload + def change_interval( + self, + *, + time: Union[datetime.time, Sequence[datetime.time]], + ) -> None: ... + def change_interval( self, *, @@ -777,6 +794,28 @@ class Loop(Generic[LF]): self._handle.recalculate(self._next_iteration) +@overload +def loop( + *, + seconds: float = 0, + minutes: float = 0, + hours: float = 0, + count: Optional[int] = None, + reconnect: bool = True, + name: Optional[str] = None, +) -> Callable[[LF], Loop[LF]]: ... + + +@overload +def loop( + *, + time: Union[datetime.time, Sequence[datetime.time]], + count: Optional[int] = None, + reconnect: bool = True, + name: Optional[str] = None, +) -> Callable[[LF], Loop[LF]]: ... + + def loop( *, seconds: float = MISSING, diff --git a/discord/gateway.py b/discord/gateway.py index 4e1f78c68..45034d01a 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -44,6 +44,11 @@ from .activity import BaseActivity from .enums import SpeakingState from .errors import ConnectionClosed +try: + import davey # type: ignore +except ImportError: + pass + _log = logging.getLogger(__name__) __all__ = ( @@ -205,6 +210,10 @@ class KeepAliveHandler(threading.Thread): def tick(self) -> None: self._last_recv = time.perf_counter() + def beat(self) -> Dict[str, Any]: + self._last_send = time.perf_counter() + return self.get_payload() + def ack(self) -> None: ack_time = time.perf_counter() self._last_ack = ack_time @@ -536,7 +545,7 @@ class DiscordWebSocket: if op == self.HEARTBEAT: if self._keep_alive: - beat = self._keep_alive.get_payload() + beat = self._keep_alive.beat() await self.send_as_json(beat) return @@ -645,6 +654,7 @@ class DiscordWebSocket: self._keep_alive.stop() self._keep_alive = None + await self.socket.close(code=4000) if isinstance(e, asyncio.TimeoutError): _log.debug('Timed out receiving packet. Attempting a reconnect.') raise ReconnectWebSocket(self.shard_id) from None @@ -812,18 +822,30 @@ class DiscordVoiceWebSocket: _max_heartbeat_timeout: float # fmt: off - IDENTIFY = 0 - SELECT_PROTOCOL = 1 - READY = 2 - HEARTBEAT = 3 - SESSION_DESCRIPTION = 4 - SPEAKING = 5 - HEARTBEAT_ACK = 6 - RESUME = 7 - HELLO = 8 - RESUMED = 9 - CLIENT_CONNECT = 12 - CLIENT_DISCONNECT = 13 + IDENTIFY = 0 + SELECT_PROTOCOL = 1 + READY = 2 + HEARTBEAT = 3 + SESSION_DESCRIPTION = 4 + SPEAKING = 5 + HEARTBEAT_ACK = 6 + RESUME = 7 + HELLO = 8 + RESUMED = 9 + CLIENTS_CONNECT = 11 + CLIENT_CONNECT = 12 + CLIENT_DISCONNECT = 13 + DAVE_PREPARE_TRANSITION = 21 + DAVE_EXECUTE_TRANSITION = 22 + DAVE_TRANSITION_READY = 23 + DAVE_PREPARE_EPOCH = 24 + MLS_EXTERNAL_SENDER = 25 + MLS_KEY_PACKAGE = 26 + MLS_PROPOSALS = 27 + MLS_COMMIT_WELCOME = 28 + MLS_ANNOUNCE_COMMIT_TRANSITION = 29 + MLS_WELCOME = 30 + MLS_INVALID_COMMIT_WELCOME = 31 # fmt: on def __init__( @@ -850,6 +872,10 @@ class DiscordVoiceWebSocket: _log.debug('Sending voice websocket frame: %s.', data) await self.ws.send_str(utils._to_json(data)) + async def send_binary(self, opcode: int, data: bytes) -> None: + _log.debug('Sending voice websocket binary frame: opcode=%s size=%d', opcode, len(data)) + await self.ws.send_bytes(bytes([opcode]) + data) + send_heartbeat = send_as_json async def resume(self) -> None: @@ -874,6 +900,7 @@ class DiscordVoiceWebSocket: 'user_id': str(state.user.id), 'session_id': state.session_id, 'token': state.token, + 'max_dave_protocol_version': state.max_dave_protocol_version, }, } await self.send_as_json(payload) @@ -943,6 +970,16 @@ class DiscordVoiceWebSocket: await self.send_as_json(payload) + async def send_transition_ready(self, transition_id: int): + payload = { + 'op': DiscordVoiceWebSocket.DAVE_TRANSITION_READY, + 'd': { + 'transition_id': transition_id, + }, + } + + await self.send_as_json(payload) + async def received_message(self, msg: Dict[str, Any]) -> None: _log.debug('Voice websocket frame received: %s', msg) op = msg['op'] @@ -959,13 +996,85 @@ class DiscordVoiceWebSocket: elif op == self.SESSION_DESCRIPTION: self._connection.mode = data['mode'] await self.load_secret_key(data) + self._connection.dave_protocol_version = data['dave_protocol_version'] + if data['dave_protocol_version'] > 0: + await self._connection.reinit_dave_session() elif op == self.HELLO: interval = data['heartbeat_interval'] / 1000.0 self._keep_alive = VoiceKeepAliveHandler(ws=self, interval=min(interval, 5.0)) self._keep_alive.start() + elif self._connection.dave_session: + state = self._connection + if op == self.DAVE_PREPARE_TRANSITION: + _log.debug( + 'Preparing for DAVE transition id %d for protocol version %d', + data['transition_id'], + data['protocol_version'], + ) + state.dave_pending_transitions[data['transition_id']] = data['protocol_version'] + if data['transition_id'] == 0: + await state._execute_transition(data['transition_id']) + else: + if data['protocol_version'] == 0 and state.dave_session: + state.dave_session.set_passthrough_mode(True, 120) + + await self.send_transition_ready(data['transition_id']) + elif op == self.DAVE_EXECUTE_TRANSITION: + _log.debug('Executing DAVE transition id %d', data['transition_id']) + await state._execute_transition(data['transition_id']) + elif op == self.DAVE_PREPARE_EPOCH: + _log.debug('Preparing for DAVE epoch %d', data['epoch']) + # When the epoch ID is equal to 1, this message indicates that a new MLS group is to be created for the given protocol version. + if data['epoch'] == 1: + state.dave_protocol_version = data['protocol_version'] + await state.reinit_dave_session() await self._hook(self, msg) + async def received_binary_message(self, msg: bytes) -> None: + self.seq_ack = struct.unpack_from('>H', msg, 0)[0] + op = msg[2] + _log.debug('Voice websocket binary frame received: %d bytes; seq=%s op=%s', len(msg), self.seq_ack, op) + state = self._connection + + if state.dave_session is None: + return + + if op == self.MLS_EXTERNAL_SENDER: + state.dave_session.set_external_sender(msg[3:]) + _log.debug('Set MLS external sender') + elif op == self.MLS_PROPOSALS: + optype = msg[3] + result = state.dave_session.process_proposals( + davey.ProposalsOperationType.append if optype == 0 else davey.ProposalsOperationType.revoke, msg[4:] + ) + if isinstance(result, davey.CommitWelcome): + await self.send_binary( + DiscordVoiceWebSocket.MLS_COMMIT_WELCOME, + result.commit + result.welcome if result.welcome else result.commit, + ) + _log.debug('MLS proposals processed') + elif op == self.MLS_ANNOUNCE_COMMIT_TRANSITION: + transition_id = struct.unpack_from('>H', msg, 3)[0] + try: + state.dave_session.process_commit(msg[5:]) + if transition_id != 0: + state.dave_pending_transitions[transition_id] = state.dave_protocol_version + await self.send_transition_ready(transition_id) + _log.debug('MLS commit processed for transition id %d', transition_id) + except Exception: + await state._recover_from_invalid_commit(transition_id) + elif op == self.MLS_WELCOME: + transition_id = struct.unpack_from('>H', msg, 3)[0] + try: + state.dave_session.process_welcome(msg[5:]) + if transition_id != 0: + state.dave_pending_transitions[transition_id] = state.dave_protocol_version + await self.send_transition_ready(transition_id) + _log.debug('MLS welcome processed for transition id %d', transition_id) + except Exception: + await state._recover_from_invalid_commit(transition_id) + async def initial_connection(self, data: Dict[str, Any]) -> None: state = self._connection state.ssrc = data['ssrc'] @@ -1045,6 +1154,8 @@ class DiscordVoiceWebSocket: msg = await asyncio.wait_for(self.ws.receive(), timeout=30.0) if msg.type is aiohttp.WSMsgType.TEXT: await self.received_message(utils._from_json(msg.data)) + elif msg.type is aiohttp.WSMsgType.BINARY: + await self.received_binary_message(msg.data) elif msg.type is aiohttp.WSMsgType.ERROR: _log.debug('Received voice %s', msg) raise ConnectionClosed(self.ws, shard_id=None) from msg.data diff --git a/discord/guild.py b/discord/guild.py index 47a8b57c0..0dd999de8 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -26,7 +26,6 @@ from __future__ import annotations import copy import datetime -import unicodedata from typing import ( Any, AsyncIterator, @@ -678,7 +677,7 @@ class Guild(Hashable): scheduled_event = ScheduledEvent(data=s, state=self._state) self._scheduled_events[scheduled_event.id] = scheduled_event - if 'soundboard_sounds' in guild: + if 'soundboard_sounds' in guild and state.cache_guild_expressions: for s in guild['soundboard_sounds']: soundboard_sound = SoundboardSound(guild=self, data=s, state=self._state) self._add_soundboard_sound(soundboard_sound) @@ -3087,7 +3086,7 @@ class Guild(Hashable): self, *, name: str, - description: str, + description: str = MISSING, emoji: str, file: File, reason: Optional[str] = None, @@ -3103,11 +3102,16 @@ class Guild(Hashable): Parameters ----------- name: :class:`str` - The sticker name. Must be at least 2 characters. + The sticker name. Must be between 2 and 30 characters. description: :class:`str` - The sticker's description. + The sticker's description. Can be an empty string or a string between 2 and 100 characters. + Defaults to an empty string if not provided. emoji: :class:`str` - The name of a unicode emoji that represents the sticker's expression. + The emoji tag associated with the sticker. This corresponds to the + ``tags`` field in Discord's API, which is used for emoji autocomplete + and suggestion purposes. For correct rendering in Discord's UI, this + should ideally be a raw Unicode emoji or the string ID + of a custom emoji. Any string up to 200 characters is accepted. file: :class:`File` The file of the sticker to upload. reason: :class:`str` @@ -3127,19 +3131,10 @@ class Guild(Hashable): """ payload = { 'name': name, + 'description': description or '', + 'tags': emoji, } - payload['description'] = description - - try: - emoji = unicodedata.name(emoji) - except TypeError: - pass - else: - emoji = emoji.replace(' ', '_') - - payload['tags'] = emoji - data = await self._state.http.create_guild_sticker(self.id, payload, file, reason) if self._state.cache_guild_expressions: return self._state.store_sticker(self, data) @@ -3872,6 +3867,39 @@ class Guild(Hashable): return roles + async def role_member_counts(self) -> Dict[Union[Object, Role], int]: + """|coro| + + Retrieves a mapping of roles to the number of members that have it. + + You must have :attr:`~Permissions.manage_roles` to do this. + + .. versionadded:: 2.7 + + Raises + ------- + Forbidden + You do not have permissions to view the role member counts. + HTTPException + Retrieving the role member counts failed. + + Returns + -------- + Dict[Union[:class:`Object`, :class:`Role`], :class:`int`] + A mapping of roles to the number of members that have it. + If a role is not found in the cache, it will be represented as an :class:`Object` + instead of a :class:`Role`. + """ + data = await self._state.http.get_role_member_counts(self.id) + result: Dict[Union[Object, Role], int] = {} + for role_id, member_count in data.items(): + role_id = int(role_id) + role = self.get_role(role_id) + if role is None: + role = Object(id=role_id, type=Role) + result[role] = member_count + return result + async def welcome_screen(self) -> WelcomeScreen: """|coro| diff --git a/discord/http.py b/discord/http.py index 7b82fddb6..05c1e69fc 100644 --- a/discord/http.py +++ b/discord/http.py @@ -551,11 +551,16 @@ class HTTPClient: self.__session = MISSING async def ws_connect(self, url: str, *, compress: int = 0) -> aiohttp.ClientWebSocketResponse: + try: + timeout: Any = aiohttp.ClientWSTimeout(ws_close=30.0) # pyright: ignore[reportCallIssue] + except (AttributeError, TypeError): + timeout = 30.0 + kwargs = { 'proxy_auth': self.proxy_auth, 'proxy': self.proxy, 'max_msg_size': 0, - 'timeout': 30.0, + 'timeout': timeout, 'autoclose': False, 'headers': { 'User-Agent': self.user_agent, @@ -1136,18 +1141,15 @@ class HTTPClient: def edit_profile(self, payload: Dict[str, Any]) -> Response[user.User]: return self.request(Route('PATCH', '/users/@me'), json=payload) - def change_my_nickname( + def edit_my_member( self, guild_id: Snowflake, - nickname: str, *, reason: Optional[str] = None, - ) -> Response[member.Nickname]: - r = Route('PATCH', '/guilds/{guild_id}/members/@me/nick', guild_id=guild_id) - payload = { - 'nick': nickname, - } - return self.request(r, json=payload, reason=reason) + **fields: Any, + ) -> Response[member.MemberWithUser]: + r = Route('PATCH', '/guilds/{guild_id}/members/@me', guild_id=guild_id) + return self.request(r, json=fields, reason=reason) def change_nickname( self, @@ -1348,7 +1350,7 @@ class HTTPClient: return self.request(r, json=params.payload, params=query, reason=reason) def join_thread(self, channel_id: Snowflake) -> Response[None]: - return self.request(Route('POST', '/channels/{channel_id}/thread-members/@me', channel_id=channel_id)) + return self.request(Route('PUT', '/channels/{channel_id}/thread-members/@me', channel_id=channel_id)) def add_user_to_thread(self, channel_id: Snowflake, user_id: Snowflake) -> Response[None]: return self.request( @@ -1909,6 +1911,9 @@ class HTTPClient: def get_role(self, guild_id: Snowflake, role_id: Snowflake) -> Response[role.Role]: return self.request(Route('GET', '/guilds/{guild_id}/roles/{role_id}', guild_id=guild_id, role_id=role_id)) + def get_role_member_counts(self, guild_id: Snowflake) -> Response[Dict[str, int]]: + return self.request(Route('GET', '/guilds/{guild_id}/roles/member-counts', guild_id=guild_id)) + def edit_role( self, guild_id: Snowflake, role_id: Snowflake, *, reason: Optional[str] = None, **fields: Any ) -> Response[role.Role]: diff --git a/discord/integrations.py b/discord/integrations.py index 5fd238f55..891bd9d02 100644 --- a/discord/integrations.py +++ b/discord/integrations.py @@ -25,7 +25,7 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations import datetime -from typing import Any, Dict, Optional, TYPE_CHECKING, Type, Tuple +from typing import Any, Dict, List, Optional, TYPE_CHECKING, Type, Tuple from .utils import _get_as_snowflake, parse_time, MISSING from .user import User from .enums import try_enum, ExpireBehaviour @@ -98,6 +98,10 @@ class Integration: The account linked to this integration. user: :class:`User` The user that added this integration. + scopes: List[:class:`str`] + The OAuth2 scopes the application has been authorized for. + + .. versionadded:: 2.7 """ __slots__ = ( @@ -109,6 +113,7 @@ class Integration: 'account', 'user', 'enabled', + 'scopes', ) def __init__(self, *, data: IntegrationPayload, guild: Guild) -> None: @@ -128,6 +133,7 @@ class Integration: user = data.get('user') self.user: Optional[User] = User(state=self._state, data=user) if user else None self.enabled: bool = data['enabled'] + self.scopes: List[str] = data.get('scopes', []) async def delete(self, *, reason: Optional[str] = None) -> None: """|coro| @@ -184,6 +190,10 @@ class StreamIntegration(Integration): The integration account information. synced_at: :class:`datetime.datetime` An aware UTC datetime representing when the integration was last synced. + scopes: List[:class:`str`] + The OAuth2 scopes the application has been authorized for. + + .. versionadded:: 2.7 """ __slots__ = ( @@ -352,6 +362,10 @@ class BotIntegration(Integration): The integration account information. application: :class:`IntegrationApplication` The application tied to this integration. + scopes: List[:class:`str`] + The OAuth2 scopes the application has been authorized for. + + .. versionadded:: 2.7 """ __slots__ = ('application',) diff --git a/discord/interactions.py b/discord/interactions.py index e295de0c6..ebcec1a6b 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -65,6 +65,8 @@ if TYPE_CHECKING: ApplicationCommandInteractionData, InteractionCallback as InteractionCallbackPayload, InteractionCallbackActivity as InteractionCallbackActivityPayload, + MessageComponentInteractionData, + ModalSubmitInteractionData, ) from .types.webhook import ( Webhook as WebhookPayload, @@ -191,6 +193,8 @@ class Interaction(Generic[ClientT]): 'channel', '_cs_namespace', '_cs_command', + '_cs_command_id', + '_cs_custom_id', ) def __init__(self, *, data: InteractionPayload, state: ConnectionState[ClientT]): @@ -376,6 +380,21 @@ class Interaction(Generic[ClientT]): else: return tree._get_context_menu(data) + @utils.cached_slot_property('_cs_command_id') + def command_id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of the command that triggered this interaction. + + Only applicable if :attr:`type` is one of, :attr:`InteractionType.application_command` or + :attr:`InteractionType.autocomplete`. + + .. versionadded:: 2.7 + """ + if self.type not in (InteractionType.application_command, InteractionType.autocomplete): + return None + + data: ApplicationCommandInteractionData = self.data # type: ignore + return int(data.get('id', 0)) + @utils.cached_slot_property('_cs_response') def response(self) -> InteractionResponse[ClientT]: """:class:`InteractionResponse`: Returns an object responsible for handling responding to the interaction. @@ -405,6 +424,21 @@ class Interaction(Generic[ClientT]): """:class:`datetime.datetime`: When the interaction expires.""" return self.created_at + datetime.timedelta(minutes=15) + @utils.cached_slot_property('_cs_custom_id') + def custom_id(self) -> Optional[str]: + """Optional[:class:`str`]: The custom ID of the component that triggered this interaction. + + Only applicable if :attr:`type` is one of, :attr:`InteractionType.component` or + :attr:`InteractionType.modal_submit`. + + .. versionadded:: 2.7 + """ + if self.type not in (InteractionType.component, InteractionType.modal_submit): + return None + + data: Union[MessageComponentInteractionData, ModalSubmitInteractionData] = self.data # type: ignore + return data.get('custom_id') + def is_expired(self) -> bool: """:class:`bool`: Returns ``True`` if the interaction is expired.""" return utils.utcnow() >= self.expires_at @@ -581,7 +615,7 @@ class Interaction(Generic[ClientT]): state = _InteractionMessageState(self, self._state) message = InteractionMessage(state=state, channel=self.channel, data=data) # type: ignore if view and not view.is_finished() and view.is_dispatchable(): - self._state.store_view(view, message.id, interaction_id=self.id) + self._state.store_view(view, message.id) return message async def delete_original_response(self) -> None: @@ -1048,7 +1082,7 @@ class InteractionResponse(Generic[ClientT]): ) http = parent._state.http - response = await adapter.create_interaction_response( + data = await adapter.create_interaction_response( parent.id, parent.token, session=parent._session, @@ -1056,17 +1090,19 @@ class InteractionResponse(Generic[ClientT]): proxy_auth=http.proxy_auth, params=params, ) + self._response_type = InteractionResponseType.channel_message + response = InteractionCallbackResponse( + data=data, + parent=self._parent, + state=self._parent._state, + type=self._response_type, + ) if view is not MISSING and not view.is_finished(): if ephemeral and view.timeout is None: view.timeout = 15 * 60.0 - # If the interaction type isn't an application command then there's no way - # to obtain this interaction_id again, so just default to None - entity_id = parent.id if parent.type is InteractionType.application_command else None - self._parent._state.store_view(view, entity_id) - - self._response_type = InteractionResponseType.channel_message + self._parent._state.store_view(view, response.message_id) if delete_after is not None: @@ -1079,12 +1115,7 @@ class InteractionResponse(Generic[ClientT]): asyncio.create_task(inner_call()) - return InteractionCallbackResponse( - data=response, - parent=self._parent, - state=self._parent._state, - type=self._response_type, - ) + return response async def edit_message( self, @@ -1171,12 +1202,8 @@ class InteractionResponse(Generic[ClientT]): state = parent._state if msg is not None: message_id = msg.id - # If this was invoked via an application command then we can use its original interaction ID - # Since this is used as a cache key for view updates - original_interaction_id = msg.interaction_metadata.id if msg.interaction_metadata is not None else None else: message_id = None - original_interaction_id = None if parent.type not in (InteractionType.component, InteractionType.modal_submit): return @@ -1204,7 +1231,7 @@ class InteractionResponse(Generic[ClientT]): ) http = parent._state.http - response = await adapter.create_interaction_response( + data = await adapter.create_interaction_response( parent.id, parent.token, session=parent._session, @@ -1212,11 +1239,16 @@ class InteractionResponse(Generic[ClientT]): proxy_auth=http.proxy_auth, params=params, ) + self._response_type = InteractionResponseType.message_update + response = InteractionCallbackResponse( + data=data, + parent=self._parent, + state=self._parent._state, + type=self._response_type, + ) 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 + state.store_view(view, message_id or response.message_id) if delete_after is not None: @@ -1229,12 +1261,7 @@ class InteractionResponse(Generic[ClientT]): asyncio.create_task(inner_call()) - return InteractionCallbackResponse( - data=response, - parent=self._parent, - state=self._parent._state, - type=self._response_type, - ) + return response async def send_modal(self, modal: Modal, /) -> InteractionCallbackResponse[ClientT]: """|coro| diff --git a/discord/member.py b/discord/member.py index fd2cf7edb..8f7342877 100644 --- a/discord/member.py +++ b/discord/member.py @@ -25,7 +25,6 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations import datetime -import inspect import itertools from operator import attrgetter from typing import Any, Awaitable, Callable, Collection, Dict, List, Optional, TYPE_CHECKING, Tuple, TypeVar, Union @@ -190,7 +189,7 @@ def flatten_user(cls: T) -> T: # probably a member function by now def generate_function(x): # We want sphinx to properly show coroutine functions as coroutines - if inspect.iscoroutinefunction(value): + if utils._iscoroutinefunction(value): async def general(self, *args, **kwargs): # type: ignore return await getattr(self._user, x)(*args, **kwargs) @@ -815,12 +814,22 @@ class Member(discord.abc.Messageable, _UserTag): voice_channel: Optional[VocalGuildChannel] = MISSING, timed_out_until: Optional[datetime.datetime] = MISSING, bypass_verification: bool = MISSING, + avatar: Optional[bytes] = MISSING, + banner: Optional[bytes] = MISSING, + bio: Optional[str] = MISSING, reason: Optional[str] = None, ) -> Optional[Member]: """|coro| Edits the member's data. + .. note:: + + To upload an avatar or banner, a :term:`py:bytes-like object` must be passed in that + represents the image being uploaded. If this is done through a file + then the file must be opened via ``open('some_filename', 'rb')`` and + the :term:`py:bytes-like object` is given through the use of ``fp.read()``. + Depending on the parameter passed, this requires different permissions listed below: +---------------------+---------------------------------------+ @@ -876,6 +885,23 @@ class Member(discord.abc.Messageable, _UserTag): Indicates if the member should be allowed to bypass the guild verification requirements. .. versionadded:: 2.2 + avatar: Optional[:class:`bytes`] + A :term:`py:bytes-like object` representing the image to upload. Could be ``None`` to denote no avatar. + Only image formats supported for uploading are JPEG, PNG, GIF, and WEBP. + This can only be set when editing the bot's own member. + + .. versionadded:: 2.7 + banner: Optional[:class:`bytes`] + A :term:`py:bytes-like object` representing the image to upload. Could be ``None`` to denote no banner. + Only image formats supported for uploading are JPEG, PNG, GIF and WEBP.. + This can only be set when editing the bot's own member. + + .. versionadded:: 2.7 + bio: Optional[:class:`str`] + The new bio for the member. Use ``None`` to remove the bio. + This can only be set when editing the bot's own member. + + .. versionadded:: 2.7 reason: Optional[:class:`str`] The reason for editing this member. Shows up on the audit log. @@ -888,6 +914,9 @@ class Member(discord.abc.Messageable, _UserTag): The operation failed. TypeError The datetime object passed to ``timed_out_until`` was not timezone-aware. + ValueError + You tried to edit the bio, avatar or banner of a member that is not the bot's own member. + Or the wrong image format passed for ``avatar`` or ``banner``. Returns -------- @@ -899,14 +928,33 @@ class Member(discord.abc.Messageable, _UserTag): guild_id = self.guild.id me = self._state.self_id == self.id payload: Dict[str, Any] = {} + self_payload: Dict[str, Any] = {} if nick is not MISSING: nick = nick or '' if me: - await http.change_my_nickname(guild_id, nick, reason=reason) + self_payload['nick'] = nick else: payload['nick'] = nick + if avatar is not MISSING: + if avatar is None: + self_payload['avatar'] = None + else: + self_payload['avatar'] = utils._bytes_to_base64_data(avatar) + + if banner is not MISSING: + if banner is None: + self_payload['banner'] = None + else: + self_payload['banner'] = utils._bytes_to_base64_data(banner) + + if bio is not MISSING: + self_payload['bio'] = bio or '' + + if not me and self_payload: + raise ValueError("Editing the bio, avatar or banner is only for the bot's own member.") + if deafen is not MISSING: payload['deaf'] = deafen @@ -928,7 +976,7 @@ class Member(discord.abc.Messageable, _UserTag): await http.edit_my_voice_state(guild_id, voice_state_payload) else: if not suppress: - voice_state_payload['request_to_speak_timestamp'] = datetime.datetime.utcnow().isoformat() + voice_state_payload['request_to_speak_timestamp'] = utils.utcnow().isoformat() await http.edit_voice_state(guild_id, self.id, voice_state_payload) if voice_channel is not MISSING: @@ -954,7 +1002,12 @@ class Member(discord.abc.Messageable, _UserTag): if payload: data = await http.edit_member(guild_id, self.id, reason=reason, **payload) - return Member(data=data, guild=self.guild, state=self._state) + elif self_payload: + data = await http.edit_my_member(guild_id, reason=reason, **self_payload) + else: + return None + + return Member(data=data, guild=self.guild, state=self._state) async def request_to_speak(self) -> None: """|coro| @@ -984,7 +1037,7 @@ class Member(discord.abc.Messageable, _UserTag): payload = { 'channel_id': self.voice.channel.id, - 'request_to_speak_timestamp': datetime.datetime.utcnow().isoformat(), + 'request_to_speak_timestamp': utils.utcnow().isoformat(), } if self._state.self_id != self.id: diff --git a/discord/message.py b/discord/message.py index 9db351d54..5192a54d2 100644 --- a/discord/message.py +++ b/discord/message.py @@ -1415,11 +1415,7 @@ class PartialMessage(Hashable): message = Message(state=self._state, channel=self.channel, data=data) 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: - self._state.store_view(view, self.id) + self._state.store_view(view, self.id) if delete_after is not None: await self.delete(delay=delete_after) @@ -1453,7 +1449,7 @@ class PartialMessage(Hashable): Pins the message. - You must have :attr:`~Permissions.manage_messages` to do + You must have :attr:`~Permissions.pin_messages` to do this in a non-private channel context. Parameters @@ -1471,7 +1467,7 @@ class PartialMessage(Hashable): The message or channel was not found or deleted. HTTPException Pinning the message failed, probably due to the channel - having more than 50 pinned messages. + having more than 250 pinned messages. """ await self._state.http.pin_message(self.channel.id, self.id, reason=reason) @@ -1483,7 +1479,7 @@ class PartialMessage(Hashable): Unpins the message. - You must have :attr:`~Permissions.manage_messages` to do + You must have :attr:`~Permissions.pin_messages` to do this in a non-private channel context. Parameters @@ -2221,6 +2217,7 @@ 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')) + self.call: Optional[CallMessage] = None # Set by Messageable.pins self._pinned_at: Optional[datetime.datetime] = None @@ -2513,11 +2510,8 @@ class Message(PartialMessage, Hashable): self.interaction_metadata = MessageInteractionMetadata(state=self._state, guild=self.guild, data=data) def _handle_call(self, data: CallMessagePayload): - self.call: Optional[CallMessage] if data is not None: self.call = CallMessage(state=self._state, message=self, data=data) - else: - self.call = None def _rebind_cached_references( self, @@ -3053,3 +3047,30 @@ class Message(PartialMessage, Hashable): The newly edited message. """ return await self.edit(attachments=[a for a in self.attachments if a not in attachments]) + + def is_forwardable(self) -> bool: + """:class:`bool`: Whether the message can be forwarded using :meth:`Message.forward`. + + A message is forwardable only if it is a basic message type and does not + contain a poll, call, or activity, and is not a system message. + + .. versionadded:: 2.7 + """ + if self.type not in ( + MessageType.default, + MessageType.reply, + MessageType.chat_input_command, + MessageType.context_menu_command, + ): + return False + + if self.poll is not None: + return False + + if self.call is not None: + return False + + if self.activity is not None: + return False + + return True diff --git a/discord/partial_emoji.py b/discord/partial_emoji.py index 502202330..3301864a6 100644 --- a/discord/partial_emoji.py +++ b/discord/partial_emoji.py @@ -39,6 +39,7 @@ __all__ = ( if TYPE_CHECKING: from typing_extensions import Self + from .client import Client from .state import ConnectionState from datetime import datetime from .types.emoji import Emoji as EmojiPayload, PartialEmoji as PartialEmojiPayload @@ -114,7 +115,7 @@ class PartialEmoji(_EmojiTag, AssetMixin): ) @classmethod - def from_str(cls, value: str) -> Self: + def from_str(cls, value: str, *, client: Client = utils.MISSING) -> Self: """Converts a Discord string representation of an emoji to a :class:`PartialEmoji`. The formats accepted are: @@ -132,6 +133,11 @@ class PartialEmoji(_EmojiTag, AssetMixin): ------------ value: :class:`str` The string representation of an emoji. + client: :class:`Client` + The client to initialise this emoji with. This allows it to + attach the client's internal state. + + .. versionadded:: 2.7 Returns -------- @@ -144,8 +150,12 @@ class PartialEmoji(_EmojiTag, AssetMixin): animated = bool(groups['animated']) emoji_id = int(groups['id']) name = groups['name'] + if client is not utils.MISSING: + return cls.with_state(name=name, animated=animated, id=emoji_id, state=client._connection) return cls(name=name, animated=animated, id=emoji_id) + if client is not utils.MISSING: + return cls.with_state(name=value, animated=False, id=None, state=client._connection) return cls(name=value, id=None, animated=False) def to_dict(self) -> EmojiPayload: @@ -245,8 +255,8 @@ class PartialEmoji(_EmojiTag, AssetMixin): if self.is_unicode_emoji(): return '' - fmt = 'gif' if self.animated else 'png' - return f'{Asset.BASE}/emojis/{self.id}.{fmt}' + end = 'webp?animated=true' if self.animated else 'png' + return f'{Asset.BASE}/emojis/{self.id}.{end}' async def read(self) -> bytes: """|coro| diff --git a/discord/permissions.py b/discord/permissions.py index a1e0d21c2..e09af313c 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -95,6 +95,7 @@ if TYPE_CHECKING: create_polls: BoolOrNoneT use_external_apps: BoolOrNoneT pin_messages: BoolOrNoneT + bypass_slowmode: BoolOrNoneT class _PermissionsKwargs(_BasePermissionsKwargs[bool]): ... @@ -253,7 +254,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_1111_0111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111) + return cls(0b0000_0000_0001_1111_0111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111) @classmethod def _timeout_mask(cls) -> int: @@ -273,6 +274,7 @@ class Permissions(BaseFlags): base.create_public_threads = False base.manage_threads = False base.send_messages_in_threads = False + base.bypass_slowmode = False return base @classmethod @@ -326,8 +328,11 @@ class Permissions(BaseFlags): .. versionchanged:: 2.4 Added :attr:`send_polls`, :attr:`send_voice_messages`, attr:`use_external_sounds`, :attr:`use_embedded_activities`, and :attr:`use_external_apps` permissions. + + .. versionchanged:: 2.7 + Added :attr:`pin_messages` and :attr:`bypass_slowmode` permissions. """ - return cls(0b0000_0000_0000_1110_0110_0100_1111_1101_1011_0011_1111_0111_1111_1111_0101_0001) + return cls(0b0000_0000_0001_1110_0110_0100_1111_1101_1011_0011_1111_0111_1111_1111_0101_0001) @classmethod def general(cls) -> Self: @@ -377,9 +382,9 @@ class Permissions(BaseFlags): Added :attr:`send_polls` and :attr:`use_external_apps` permissions. .. versionchanged:: 2.7 - Added :attr:`pin_messages` permission. + Added :attr:`pin_messages` and :attr:`bypass_slowmode` permissions. """ - return cls(0b0000_0000_0000_1110_0100_0000_0111_1100_1000_0000_0000_0111_1111_1000_0100_0000) + return cls(0b0000_0000_0001_1110_0100_0000_0111_1100_1000_0000_0000_0111_1111_1000_0100_0000) @classmethod def voice(cls) -> Self: @@ -577,7 +582,7 @@ class Permissions(BaseFlags): @flag_value def manage_messages(self) -> int: - """:class:`bool`: Returns ``True`` if a user can delete messages or bypass slowmode in a text channel. + """:class:`bool`: Returns ``True`` if a user can delete messages in a text channel. .. note:: @@ -884,6 +889,14 @@ class Permissions(BaseFlags): """ return 1 << 51 + @flag_value + def bypass_slowmode(self) -> int: + """:class:`bool`: Returns ``True`` if a user can bypass slowmode. + + .. versionadded:: 2.7 + """ + return 1 << 52 + def _augment_from_permissions(cls): cls.VALID_NAMES = set(Permissions.VALID_FLAGS) @@ -1009,6 +1022,7 @@ class PermissionOverwrite: create_polls: Optional[bool] use_external_apps: Optional[bool] pin_messages: Optional[bool] + bypass_slowmode: Optional[bool] def __init__(self, **kwargs: Unpack[_PermissionOverwriteKwargs]) -> None: self._values: Dict[str, Optional[bool]] = {} diff --git a/discord/player.py b/discord/player.py index 6243c0417..2a1fdf95d 100644 --- a/discord/player.py +++ b/discord/player.py @@ -40,7 +40,7 @@ import io from typing import Any, Callable, Generic, IO, Optional, TYPE_CHECKING, Tuple, TypeVar, Union from .enums import SpeakingState -from .errors import ClientException +from .errors import ClientException, FFmpegProcessError from .opus import Encoder as OpusEncoder, OPUS_SILENCE from .oggparse import OggStream from .utils import MISSING @@ -186,6 +186,8 @@ class FFmpegAudio(AudioSource): self._stderr: Optional[IO[bytes]] = None self._pipe_writer_thread: Optional[threading.Thread] = None self._pipe_reader_thread: Optional[threading.Thread] = None + self._current_error: Optional[Exception] = None + self._stopped: bool = False if piping_stdin: n = f'popen-stdin-writer:pid-{self._process.pid}' @@ -212,25 +214,72 @@ class FFmpegAudio(AudioSource): else: return process + def _check_process_returncode(self) -> None: + """Set _current_error if FFmpeg exited with a non-zero code.""" + if self._process is MISSING: + return + + ret = self._process.poll() + if ret is None: + return # still running + + if self._stopped: + return # intentionally stopped + + if ret != 0 and self._current_error is None: + # Only set error once, on first detection + # read stderr if available + stderr_text = None + if self._stderr: + try: + stderr_text = self._stderr.read(8192).decode(errors='ignore') + except Exception: + stderr_text = '' + + stderr_info = stderr_text if stderr_text else '' + self._current_error = FFmpegProcessError(f'FFmpeg exited with code {ret}. Stderr: {stderr_info}') + def _kill_process(self) -> None: + # check if FFmpeg process failed + self._check_process_returncode() + # this function gets called in __del__ so instance attributes might not even exist proc = getattr(self, '_process', MISSING) + # Only proceed if proc is a subprocess.Popen instance if proc is MISSING: return - _log.debug('Preparing to terminate ffmpeg process %s.', proc.pid) + pid = getattr(proc, 'pid', 'unknown') + _log.debug('Preparing to terminate ffmpeg process %s.', pid) try: proc.kill() except Exception: - _log.exception('Ignoring error attempting to kill ffmpeg process %s', proc.pid) + _log.exception('Ignoring error attempting to kill ffmpeg process %s', pid) + + try: + still_running = proc.poll() is None + except Exception: + _log.exception('Error checking poll() on ffmpeg process %s', pid) + still_running = False - if proc.poll() is None: - _log.info('ffmpeg process %s has not terminated. Waiting to terminate...', proc.pid) - proc.communicate() - _log.info('ffmpeg process %s should have terminated with a return code of %s.', proc.pid, proc.returncode) + if still_running: + _log.info('ffmpeg process %s has not terminated. Waiting to terminate...', pid) + try: + proc.communicate() + except Exception: + pass + _log.info( + 'ffmpeg process %s should have terminated with a return code of %s.', + pid, + getattr(proc, 'returncode', 'unknown'), + ) else: - _log.info('ffmpeg process %s successfully terminated with return code of %s.', proc.pid, proc.returncode) + _log.info( + 'ffmpeg process %s successfully terminated with return code of %s.', + pid, + getattr(proc, 'returncode', 'unknown'), + ) def _pipe_writer(self, source: io.BufferedIOBase) -> None: while self._process: @@ -267,6 +316,7 @@ class FFmpegAudio(AudioSource): return def cleanup(self) -> None: + self._stopped = True self._kill_process() self._process = self._stdout = self._stdin = self._stderr = MISSING @@ -348,6 +398,8 @@ class FFmpegPCMAudio(FFmpegAudio): def read(self) -> bytes: ret = self._stdout.read(OpusEncoder.FRAME_SIZE) if len(ret) != OpusEncoder.FRAME_SIZE: + # Check for FFmpeg process failure when read returns incomplete data + self._check_process_returncode() return b'' return ret @@ -646,7 +698,11 @@ class FFmpegOpusAudio(FFmpegAudio): return codec, bitrate def read(self) -> bytes: - return next(self._packet_iter, b'') + data = next(self._packet_iter, b'') + if not data: + # Check for FFmpeg process failure when read returns empty + self._check_process_returncode() + return data def is_opus(self) -> bool: return True @@ -745,6 +801,11 @@ class AudioPlayer(threading.Thread): data = self.source.read() if not data: + # Check if the source has an error (e.g., from FFmpegAudio process failure) + if self._current_error is None: + source_error = getattr(self.source, '_current_error', None) + if source_error: + self._current_error = source_error self.stop() break diff --git a/discord/shard.py b/discord/shard.py index 7198887cf..b01818bf8 100644 --- a/discord/shard.py +++ b/discord/shard.py @@ -41,6 +41,7 @@ from .errors import ( ConnectionClosed, PrivilegedIntentsRequired, ) +from .utils import MISSING from .enums import Status @@ -389,6 +390,7 @@ class AutoShardedClient(Client): self.__shards = {} self._connection._get_websocket = self._get_websocket self._connection._get_client = lambda: self + self.__queue: asyncio.PriorityQueue = MISSING def _get_websocket(self, guild_id: Optional[int] = None, *, shard_id: Optional[int] = None) -> DiscordWebSocket: if shard_id is None: @@ -554,7 +556,8 @@ class AutoShardedClient(Client): await asyncio.wait(to_close) await self.http.close() - self.__queue.put_nowait(EventItem(EventType.clean_close, None, None)) + if self.__queue is not MISSING: + self.__queue.put_nowait(EventItem(EventType.clean_close, None, None)) self._closing_task = asyncio.create_task(_close()) await self._closing_task diff --git a/discord/state.py b/discord/state.py index 74922907d..9c119acfc 100644 --- a/discord/state.py +++ b/discord/state.py @@ -279,7 +279,7 @@ class ConnectionState(Generic[ClientT]): # So this is checked instead, it's a small penalty to pay @property def cache_guild_expressions(self) -> bool: - return self._intents.emojis_and_stickers + return self._intents.expressions async def close(self) -> None: for voice in self.voice_clients: @@ -412,9 +412,7 @@ class ConnectionState(Generic[ClientT]): self._stickers[sticker_id] = sticker = GuildSticker(state=self, data=data) return sticker - 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) + def store_view(self, view: BaseView, message_id: Optional[int] = None) -> None: self._view_store.add_view(view, message_id) def prevent_view_updates_for(self, message_id: int) -> Optional[BaseView]: @@ -828,7 +826,8 @@ class ConnectionState(Generic[ClientT]): inner_data = data['data'] custom_id = inner_data['custom_id'] components = inner_data['components'] - self._view_store.dispatch_modal(custom_id, interaction, components) + resolved = inner_data.get('resolved', {}) + self._view_store.dispatch_modal(custom_id, interaction, components, resolved) self.dispatch('interaction', interaction) def parse_presence_update(self, data: gw.PresenceUpdateEvent) -> None: diff --git a/discord/threads.py b/discord/threads.py index 1700a5e61..daa3a4a2b 100644 --- a/discord/threads.py +++ b/discord/threads.py @@ -103,8 +103,7 @@ class Thread(Messageable, Hashable): 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:`~Permissions.manage_channels` or - :attr:`~Permissions.manage_messages` bypass slowmode. + Bots and users with :attr:`~Permissions.bypass_slowmode` bypass slowmode. message_count: :class:`int` An approximate number of messages in this thread. member_count: :class:`int` diff --git a/discord/types/components.py b/discord/types/components.py index bb75a918f..0d7b6d80d 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -30,7 +30,7 @@ from typing_extensions import NotRequired from .emoji import PartialEmoji from .channel import ChannelType -ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17, 18] +ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17, 18, 19, 21, 22, 23] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] TextStyle = Literal[1, 2] DefaultValueType = Literal['user', 'role', 'channel'] @@ -43,6 +43,13 @@ class ComponentBase(TypedDict): type: int +class OptionBase(TypedDict): + label: str + value: str + default: NotRequired[bool] + description: NotRequired[str] + + class ActionRow(ComponentBase): type: Literal[1] components: List[ActionRowChildComponent] @@ -59,11 +66,7 @@ class ButtonComponent(ComponentBase): sku_id: NotRequired[str] -class SelectOption(TypedDict): - label: str - value: str - default: bool - description: NotRequired[str] +class SelectOption(OptionBase): emoji: NotRequired[PartialEmoji] @@ -192,7 +195,43 @@ class LabelComponent(ComponentBase): type: Literal[18] label: str description: NotRequired[str] - component: Union[StringSelectComponent, TextInput] + component: LabelChildComponent + + +class FileUploadComponent(ComponentBase): + type: Literal[19] + custom_id: str + max_values: NotRequired[int] + min_values: NotRequired[int] + required: NotRequired[bool] + + +class RadioGroupComponent(ComponentBase): + type: Literal[21] + custom_id: str + options: NotRequired[List[RadioGroupOption]] + required: NotRequired[bool] + + +RadioGroupOption = OptionBase + + +class CheckboxGroupComponent(ComponentBase): + type: Literal[22] + custom_id: str + options: NotRequired[List[CheckboxGroupOption]] + max_values: NotRequired[int] + min_values: NotRequired[int] + required: NotRequired[bool] + + +CheckboxGroupOption = OptionBase + + +class CheckboxComponent(ComponentBase): + type: Literal[23] + custom_id: str + default: NotRequired[bool] ActionRowChildComponent = Union[ButtonComponent, SelectMenu, TextInput] @@ -203,8 +242,21 @@ ContainerChildComponent = Union[ FileComponent, SectionComponent, SectionComponent, - ContainerComponent, SeparatorComponent, ThumbnailComponent, ] -Component = Union[ActionRowChildComponent, LabelComponent, ContainerChildComponent] +LabelChildComponent = Union[ + TextInput, + SelectMenu, + FileUploadComponent, + RadioGroupComponent, + CheckboxGroupComponent, + CheckboxComponent, +] +Component = Union[ + ActionRowChildComponent, + LabelComponent, + LabelChildComponent, + ContainerChildComponent, + ContainerComponent, +] diff --git a/discord/types/interactions.py b/discord/types/interactions.py index f34166959..463800a90 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -27,7 +27,12 @@ from __future__ import annotations from typing import TYPE_CHECKING, Dict, List, Literal, TypedDict, Union, Optional from typing_extensions import NotRequired -from .channel import ChannelTypeWithoutThread, GuildChannel, InteractionDMChannel, GroupDMChannel +from .channel import ( + ChannelTypeWithoutThread, + GuildChannel, + InteractionDMChannel, + GroupDMChannel, +) from .sku import Entitlement from .threads import ThreadType, ThreadMetadata from .member import Member @@ -36,6 +41,7 @@ from .role import Role from .snowflake import Snowflake from .user import User from .guild import GuildFeature +from .components import ComponentBase if TYPE_CHECKING: from .message import Message @@ -204,39 +210,81 @@ class SelectMessageComponentInteractionData(_BaseMessageComponentInteractionData MessageComponentInteractionData = Union[ButtonMessageComponentInteractionData, SelectMessageComponentInteractionData] -class ModalSubmitTextInputInteractionData(TypedDict): +class ModalSubmitTextInputInteractionData(ComponentBase): type: Literal[4] custom_id: str value: str -class ModalSubmitStringSelectInteractionData(TypedDict): - type: Literal[3] +class ModalSubmitSelectInteractionData(ComponentBase): + type: Literal[3, 5, 6, 7, 8] + custom_id: str + values: List[str] + + +class ModalSubmitFileUploadInteractionData(ComponentBase): + type: Literal[19] + custom_id: str + values: List[str] + + +class ModalSubmitRadioGroupInteractionData(ComponentBase): + type: Literal[21] + custom_id: str + id: int + value: Optional[str] + + +class ModalSubmitCheckboxGroupInteractionData(ComponentBase): + type: Literal[22] custom_id: str + id: int values: List[str] -ModalSubmitComponentItemInteractionData = Union[ModalSubmitTextInputInteractionData, ModalSubmitStringSelectInteractionData] +class ModalSubmitCheckboxInteractionData(ComponentBase): + type: Literal[23] + custom_id: str + id: int + value: bool + + +ModalSubmitLabelComponentItemInteractionData = Union[ + ModalSubmitSelectInteractionData, + ModalSubmitTextInputInteractionData, + ModalSubmitFileUploadInteractionData, + ModalSubmitRadioGroupInteractionData, + ModalSubmitCheckboxGroupInteractionData, + ModalSubmitCheckboxInteractionData, +] class ModalSubmitActionRowInteractionData(TypedDict): type: Literal[1] - components: List[ModalSubmitComponentItemInteractionData] + components: List[ModalSubmitTextInputInteractionData] -class ModalSubmitLabelInteractionData(TypedDict): +class ModalSubmitTextDisplayInteractionData(ComponentBase): + type: Literal[10] + content: str + + +class ModalSubmitLabelInteractionData(ComponentBase): type: Literal[18] - component: ModalSubmitComponentItemInteractionData + component: ModalSubmitLabelComponentItemInteractionData ModalSubmitComponentInteractionData = Union[ - ModalSubmitLabelInteractionData, ModalSubmitActionRowInteractionData, ModalSubmitComponentItemInteractionData + ModalSubmitActionRowInteractionData, + ModalSubmitTextDisplayInteractionData, + ModalSubmitLabelInteractionData, ] class ModalSubmitInteractionData(TypedDict): custom_id: str components: List[ModalSubmitComponentInteractionData] + resolved: NotRequired[ResolvedData] InteractionData = Union[ @@ -284,7 +332,12 @@ class ModalSubmitInteraction(_BaseInteraction): data: ModalSubmitInteractionData -Interaction = Union[PingInteraction, ApplicationCommandInteraction, MessageComponentInteraction, ModalSubmitInteraction] +Interaction = Union[ + PingInteraction, + ApplicationCommandInteraction, + MessageComponentInteraction, + ModalSubmitInteraction, +] class MessageInteraction(TypedDict): @@ -332,7 +385,8 @@ class MessageComponentMessageInteractionMetadata(_MessageInteractionMetadata): class ModalSubmitMessageInteractionMetadata(_MessageInteractionMetadata): type: Literal[5] triggering_interaction_metadata: Union[ - ApplicationCommandMessageInteractionMetadata, MessageComponentMessageInteractionMetadata + ApplicationCommandMessageInteractionMetadata, + MessageComponentMessageInteractionMetadata, ] diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index 2ce3655ed..c5ce5e390 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -25,3 +25,6 @@ from .text_display import * from .thumbnail import * from .action_row import * from .label import * +from .file_upload import * +from .radio import * +from .checkbox import * diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index c7f7a2b7b..863564973 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -24,12 +24,12 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations +import copy from typing import ( TYPE_CHECKING, Any, Callable, ClassVar, - Coroutine, Dict, Generator, List, @@ -42,7 +42,7 @@ from typing import ( overload, ) -from .item import Item, ContainedItemCallbackType as ItemCallbackType +from .item import Item, ContainedItemCallbackType as ItemCallbackType, _ItemCallback from .button import Button, button as _button from .select import select as _select, Select, UserSelect, RoleSelect, ChannelSelect, MentionableSelect from ..components import ActionRow as ActionRowComponent @@ -65,7 +65,6 @@ if TYPE_CHECKING: ) from ..emoji import Emoji from ..components import SelectOption - from ..interactions import Interaction from .container import Container from .dynamic import DynamicItem @@ -77,18 +76,6 @@ 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. @@ -143,8 +130,9 @@ class ActionRow(Item[V]): ) -> 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) + for child in children: + self.add_item(child) if self._weight > 5: raise ValueError('maximum number of children exceeded') @@ -173,8 +161,8 @@ class ActionRow(Item[V]): 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) + item.callback = _ItemCallback(func, self, item) # type: ignore + item._parent = self setattr(self, func.__name__, item) children.append(item) return children @@ -184,6 +172,23 @@ class ActionRow(Item[V]): for child in self._children: child._view = view + def copy(self) -> ActionRow[V]: + new = copy.copy(self) + children = [] + for child in new._children: + newch = child.copy() + newch._parent = new + if isinstance(newch.callback, _ItemCallback): + newch.callback.parent = new + children.append(newch) + new._children = children + new._parent = self._parent + new._update_view(self.view) + return new + + def __deepcopy__(self, memo) -> ActionRow[V]: + return self.copy() + def _has_children(self): return True @@ -269,7 +274,7 @@ class ActionRow(Item[V]): item._update_view(self.view) item._parent = self - self._weight += 1 + self._weight += item.width self._children.append(item) return self @@ -293,7 +298,7 @@ class ActionRow(Item[V]): else: if self._view: self._view._add_count(-1) - self._weight -= 1 + self._weight -= item.width return self diff --git a/discord/ui/button.py b/discord/ui/button.py index f80065963..a0d6258ff 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -26,14 +26,14 @@ 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, ContainedItemCallbackType as ItemCallbackType +from .item import Item, ContainedItemCallbackType as ItemCallbackType, _ItemCallback from ..enums import ButtonStyle, ComponentType from ..partial_emoji import PartialEmoji, _EmojiTag from ..components import Button as ButtonComponent +from ..utils import _iscoroutinefunction __all__ = ( 'Button', @@ -304,6 +304,9 @@ class Button(Item[V]): sku_id=self.sku_id, id=self.id, ) + if isinstance(new.callback, _ItemCallback): + new.callback.item = new + new._update_view(self.view) return new def __deepcopy__(self, memo) -> Self: @@ -367,7 +370,7 @@ def button( """ def decorator(func: ItemCallbackType[S, Button[V]]) -> ItemCallbackType[S, Button[V]]: - if not inspect.iscoroutinefunction(func): + if not _iscoroutinefunction(func): raise TypeError('button function must be a coroutine function') func.__discord_ui_model_type__ = Button diff --git a/discord/ui/checkbox.py b/discord/ui/checkbox.py new file mode 100644 index 000000000..e64895ed2 --- /dev/null +++ b/discord/ui/checkbox.py @@ -0,0 +1,391 @@ +""" +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, List, Literal, Optional, Tuple, TypeVar, Dict + +import os + +from ..utils import MISSING +from ..components import CheckboxGroupComponent, CheckboxComponent, CheckboxGroupOption +from ..enums import ComponentType +from .item import Item + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..interactions import Interaction + from ..types.interactions import ( + ModalSubmitCheckboxGroupInteractionData as ModalSubmitCheckboxGroupInteractionDataPayload, + ModalSubmitCheckboxInteractionData as ModalSubmitCheckboxInteractionDataPayload, + ) + from ..types.components import ( + CheckboxGroupComponent as CheckboxGroupComponentPayload, + CheckboxComponent as CheckboxComponentPayload, + ) + from .view import BaseView + from ..app_commands.namespace import ResolveKey + + +# fmt: off +__all__ = ( + 'CheckboxGroup', + 'Checkbox', +) +# fmt: on + +V = TypeVar('V', bound='BaseView', covariant=True) + + +class CheckboxGroup(Item[V]): + """Represents a checkbox group component within a modal. + + .. versionadded:: 2.7 + + Parameters + ------------ + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + custom_id: Optional[:class:`str`] + The custom ID of the component. + options: List[:class:`discord.CheckboxGroupOption`] + A list of options that can be selected in this checkbox group. + Can only contain up to 10 items. + max_values: Optional[:class:`int`] + The maximum number of options that can be selected in this component. + Must be between 1 and 10. Defaults to 1. + min_values: Optional[:class:`int`] + The minimum number of options that must be selected in this component. + Must be between 0 and 10. Defaults to 0. + required: :class:`bool` + Whether this component is required to be filled before submitting the modal. + Defaults to ``True``. + """ + + __item_repr_attributes__: Tuple[str, ...] = ( + 'id', + 'custom_id', + 'options', + 'required', + ) + + def __init__( + self, + *, + custom_id: str = MISSING, + required: bool = True, + min_values: Optional[int] = None, + max_values: Optional[int] = None, + options: List[CheckboxGroupOption] = MISSING, + id: Optional[int] = None, + ) -> None: + super().__init__() + self._provided_custom_id = custom_id is not MISSING + custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id + if not isinstance(custom_id, str): + raise TypeError(f'expected custom_id to be str not {custom_id.__class__.__name__}') + + self._underlying: CheckboxGroupComponent = CheckboxGroupComponent._raw_construct( + id=id, + custom_id=custom_id, + required=required, + options=options or [], + min_values=min_values, + max_values=max_values, + ) + self.id = id + self._values: List[str] = [] + + @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 + + @property + def values(self) -> List[str]: + """List[:class:`str`]: A list of values that have been selected by the user.""" + return self._values + + @property + def custom_id(self) -> str: + """:class:`str`: The ID of the component that gets received during an interaction.""" + return self._underlying.custom_id + + @custom_id.setter + def custom_id(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError('custom_id must be a str') + + self._underlying.custom_id = value + self._provided_custom_id = True + + @property + def type(self) -> Literal[ComponentType.checkbox_group]: + """:class:`.ComponentType`: The type of this component.""" + return ComponentType.checkbox_group + + @property + def options(self) -> List[CheckboxGroupOption]: + """List[:class:`discord.CheckboxGroupOption`]: A list of options that can be selected in this menu.""" + return self._underlying.options + + @options.setter + def options(self, value: List[CheckboxGroupOption]) -> None: + if not isinstance(value, list) or not all(isinstance(obj, CheckboxGroupOption) for obj in value): + raise TypeError('options must be a list of CheckboxGroupOption') + self._underlying.options = value + + @property + def min_values(self) -> int: + """:class:`int`: The minimum number of options that must be selected before submitting the modal.""" + return self._underlying.min_values + + @min_values.setter + def min_values(self, value: int) -> None: + self._underlying.min_values = int(value) + + @property + def max_values(self) -> int: + """:class:`int`: The maximum number of options that can be selected before submitting the modal.""" + return self._underlying.max_values + + @max_values.setter + def max_values(self, value: int) -> None: + self._underlying.max_values = int(value) + + def add_option( + self, + *, + label: str, + value: str = MISSING, + description: Optional[str] = None, + default: bool = False, + ) -> None: + """Adds an option to the checkbox group. + + To append a pre-existing :class:`discord.CheckboxGroupOption` use the + :meth:`append_option` method instead. + + Parameters + ----------- + label: :class:`str` + The label of the option. This is displayed to users. + Can only be up to 100 characters. + value: :class:`str` + The value of the option. This is not displayed to users. + If not given, defaults to the label. + Can only be up to 100 characters. + description: Optional[:class:`str`] + An additional description of the option, if any. + Can only be up to 100 characters. + default: :class:`bool` + Whether this option is selected by default. + + Raises + ------- + ValueError + The number of options exceeds 10. + """ + + option = CheckboxGroupOption( + label=label, + value=value, + description=description, + default=default, + ) + + self.append_option(option) + + def append_option(self, option: CheckboxGroupOption) -> None: + """Appends an option to the checkbox group. + + Parameters + ----------- + option: :class:`discord.CheckboxGroupOption` + The option to append to the checkbox group. + + Raises + ------- + ValueError + The number of options exceeds 10. + """ + + if len(self._underlying.options) >= 10: + raise ValueError('maximum number of options already provided (10)') + + self._underlying.options.append(option) + + @property + def required(self) -> bool: + """:class:`bool`: Whether the component is required or not.""" + return self._underlying.required + + @required.setter + def required(self, value: bool) -> None: + self._underlying.required = bool(value) + + @property + def width(self) -> int: + return 5 + + def to_component_dict(self) -> CheckboxGroupComponentPayload: + return self._underlying.to_dict() + + def _refresh_component(self, component: CheckboxGroupComponent) -> None: + self._underlying = component + + def _handle_submit( + self, interaction: Interaction, data: ModalSubmitCheckboxGroupInteractionDataPayload, resolved: Dict[ResolveKey, Any] + ) -> None: + self._values = data.get('values', []) + + @classmethod + def from_component(cls, component: CheckboxGroupComponent) -> Self: + self = cls( + id=component.id, + custom_id=component.custom_id, + options=component.options, + required=component.required, + min_values=component.min_values, + max_values=component.max_values, + ) + return self + + def is_dispatchable(self) -> bool: + return False + + +class Checkbox(Item[V]): + """Represents a checkbox component within a modal. + + .. versionadded:: 2.7 + + Parameters + ------------ + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + custom_id: Optional[:class:`str`] + The custom ID of the component. + default: :class:`bool` + Whether this checkbox is selected by default. + """ + + __item_repr_attributes__: Tuple[str, ...] = ( + 'id', + 'custom_id', + 'default', + ) + + def __init__( + self, + *, + custom_id: str = MISSING, + default: bool = False, + id: Optional[int] = None, + ) -> None: + super().__init__() + self._provided_custom_id = custom_id is not MISSING + custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id + if not isinstance(custom_id, str): + raise TypeError(f'expected custom_id to be str not {custom_id.__class__.__name__}') + + self._underlying: CheckboxComponent = CheckboxComponent._raw_construct( + id=id, + custom_id=custom_id, + default=default, + ) + self.id = id + self._value: bool = default + + @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 + + @property + def value(self) -> bool: + """:class:`bool`: ``True`` if this checkbox was selected, otherwise ``False``.""" + return self._value + + @property + def custom_id(self) -> str: + """:class:`str`: The ID of the component that gets received during an interaction.""" + return self._underlying.custom_id + + @custom_id.setter + def custom_id(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError('custom_id must be a str') + + self._underlying.custom_id = value + self._provided_custom_id = True + + @property + def type(self) -> Literal[ComponentType.checkbox]: + """:class:`.ComponentType`: The type of this component.""" + return ComponentType.checkbox + + @property + def default(self) -> bool: + """:class:`bool`: Whether this checkbox is selected by default.""" + return self._underlying.default + + @default.setter + def default(self, value: bool) -> None: + self._underlying.default = bool(value) + + @property + def width(self) -> int: + return 5 + + def to_component_dict(self) -> CheckboxComponentPayload: + return self._underlying.to_dict() + + def _refresh_component(self, component: CheckboxComponent) -> None: + self._underlying = component + + def _handle_submit( + self, interaction: Interaction, data: ModalSubmitCheckboxInteractionDataPayload, resolved: Dict[ResolveKey, Any] + ) -> None: + self._value = data.get('value', False) + + @classmethod + def from_component(cls, component: CheckboxComponent) -> Self: + self = cls( + id=component.id, + custom_id=component.custom_id, + default=component.default, + ) + return self + + def is_dispatchable(self) -> bool: + return False diff --git a/discord/ui/container.py b/discord/ui/container.py index 43f3ec1ee..600b0687b 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -29,7 +29,6 @@ from typing import ( TYPE_CHECKING, Any, ClassVar, - Coroutine, Dict, Generator, List, @@ -39,7 +38,7 @@ from typing import ( Union, ) -from .item import Item, ContainedItemCallbackType as ItemCallbackType +from .item import Item, ContainedItemCallbackType as ItemCallbackType, _ItemCallback from .view import _component_to_item, LayoutView from ..enums import ComponentType from ..utils import get as _utils_get @@ -49,7 +48,6 @@ if TYPE_CHECKING: from typing_extensions import Self from ..components import Container as ContainerComponent - from ..interactions import Interaction from .dynamic import DynamicItem S = TypeVar('S', bound='Container', covariant=True) @@ -58,18 +56,6 @@ 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. @@ -163,7 +149,7 @@ class Container(Item[V]): # 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 + item.callback = _ItemCallback(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 @@ -196,6 +182,15 @@ class Container(Item[V]): child._update_view(view) return True + def copy(self) -> Container[V]: + new = copy.deepcopy(self) + for child in new._children: + newch = child.copy() + newch._parent = new + new._parent = self._parent + new._update_view(self.view) + return new + def _has_children(self): return True @@ -208,10 +203,6 @@ class Container(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``.""" diff --git a/discord/ui/file.py b/discord/ui/file.py index 92b927ac0..acebc5ace 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -100,7 +100,15 @@ class File(Item[V]): spoiler=bool(spoiler), id=id, ) - self.id = id + + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this file component.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value def _is_v2(self): return True diff --git a/discord/ui/file_upload.py b/discord/ui/file_upload.py new file mode 100644 index 000000000..a2b889a44 --- /dev/null +++ b/discord/ui/file_upload.py @@ -0,0 +1,199 @@ +""" +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, List, Literal, Optional, Tuple, TypeVar, Dict + +import os + +from ..utils import MISSING +from ..components import FileUploadComponent +from ..enums import ComponentType +from .item import Item + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..message import Attachment + from ..interactions import Interaction + from ..types.interactions import ModalSubmitTextInputInteractionData as ModalSubmitFileUploadInteractionDataPayload + from ..types.components import FileUploadComponent as FileUploadComponentPayload + from .view import BaseView + from ..app_commands.namespace import ResolveKey + + +# fmt: off +__all__ = ( + 'FileUpload', +) +# fmt: on + +V = TypeVar('V', bound='BaseView', covariant=True) + + +class FileUpload(Item[V]): + """Represents a file upload component within a modal. + + .. versionadded:: 2.7 + + Parameters + ------------ + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + custom_id: Optional[:class:`str`] + The custom ID of the file upload component. + max_values: Optional[:class:`int`] + The maximum number of files that can be uploaded in this component. + Must be between 1 and 10. Defaults to 1. + min_values: Optional[:class:`int`] + The minimum number of files that must be uploaded in this component. + Must be between 0 and 10. Defaults to 0. + required: :class:`bool` + Whether this component is required to be filled before submitting the modal. + Defaults to ``True``. + """ + + __item_repr_attributes__: Tuple[str, ...] = ( + 'id', + 'custom_id', + 'max_values', + 'min_values', + 'required', + ) + + def __init__( + self, + *, + custom_id: str = MISSING, + required: bool = True, + min_values: Optional[int] = None, + max_values: Optional[int] = None, + id: Optional[int] = None, + ) -> None: + super().__init__() + self._provided_custom_id = custom_id is not MISSING + custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id + if not isinstance(custom_id, str): + raise TypeError(f'expected custom_id to be str not {custom_id.__class__.__name__}') + + self._underlying: FileUploadComponent = FileUploadComponent._raw_construct( + id=id, + custom_id=custom_id, + max_values=max_values, + min_values=min_values, + required=required, + ) + self.id = id + self._values: List[Attachment] = [] + + @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 + + @property + def values(self) -> List[Attachment]: + """List[:class:`discord.Attachment`]: The list of attachments uploaded by the user. + + You can call :meth:`~discord.Attachment.to_file` on each attachment + to get a :class:`~discord.File` for sending. + """ + return self._values + + @property + def custom_id(self) -> str: + """:class:`str`: The ID of the component that gets received during an interaction.""" + return self._underlying.custom_id + + @custom_id.setter + def custom_id(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError('custom_id must be a str') + + self._underlying.custom_id = value + self._provided_custom_id = True + + @property + def min_values(self) -> int: + """:class:`int`: The minimum number of files that must be user upload before submitting the modal.""" + return self._underlying.min_values + + @min_values.setter + def min_values(self, value: int) -> None: + self._underlying.min_values = int(value) + + @property + def max_values(self) -> int: + """:class:`int`: The maximum number of files that the user must upload before submitting the modal.""" + return self._underlying.max_values + + @max_values.setter + def max_values(self, value: int) -> None: + self._underlying.max_values = int(value) + + @property + def required(self) -> bool: + """:class:`bool`: Whether the component is required or not.""" + return self._underlying.required + + @required.setter + def required(self, value: bool) -> None: + self._underlying.required = bool(value) + + @property + def width(self) -> int: + return 5 + + def to_component_dict(self) -> FileUploadComponentPayload: + return self._underlying.to_dict() + + def _refresh_component(self, component: FileUploadComponent) -> None: + self._underlying = component + + def _handle_submit( + self, interaction: Interaction, data: ModalSubmitFileUploadInteractionDataPayload, resolved: Dict[ResolveKey, Any] + ) -> None: + self._values = [v for k, v in resolved.items() if k.id in data.get('values', [])] + + @classmethod + def from_component(cls, component: FileUploadComponent) -> Self: + self = cls( + id=component.id, + custom_id=component.custom_id, + max_values=component.max_values, + min_values=component.min_values, + required=component.required, + ) + return self + + @property + def type(self) -> Literal[ComponentType.file_upload]: + return self._underlying.type + + def is_dispatchable(self) -> bool: + return False diff --git a/discord/ui/item.py b/discord/ui/item.py index 5498dc20f..c6f165d5c 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -45,6 +45,7 @@ if TYPE_CHECKING: from .action_row import ActionRow from .container import Container from .dynamic import DynamicItem + from ..app_commands.namespace import ResolveKey I = TypeVar('I', bound='Item[Any]') V = TypeVar('V', bound='BaseView', covariant=True) @@ -54,6 +55,21 @@ ItemCallbackType = Callable[[V, Interaction[Any], I], Coroutine[Any, Any, Any]] ContainedItemCallbackType = Callable[[C, Interaction[Any], I], Coroutine[Any, Any, Any]] +class _ItemCallback: + __slots__ = ('parent', 'callback', 'item') + + def __init__(self, callback: ContainedItemCallbackType[Any, Any], parent: Any, item: Item[Any]) -> None: + self.callback: ItemCallbackType[Any, Any] = callback + self.parent: Any = parent + self.item: Item[Any] = item + + def __repr__(self) -> str: + return f'' + + def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: + return self.callback(self.parent, interaction, self.item) + + class Item(Generic[V]): """Represents the base UI item that all UI components inherit from. @@ -71,6 +87,9 @@ class Item(Generic[V]): - :class:`discord.ui.TextDisplay` - :class:`discord.ui.Thumbnail` - :class:`discord.ui.Label` + - :class:`discord.ui.RadioGroup` + - :class:`discord.ui.CheckboxGroup` + - :class:`discord.ui.Checkbox` .. versionadded:: 2.0 """ @@ -97,6 +116,9 @@ class Item(Generic[V]): def _refresh_component(self, component: Component) -> None: return None + def _handle_submit(self, interaction: Interaction, data: Dict[str, Any], resolved: Dict[ResolveKey, Any]) -> None: + return self._refresh_state(interaction, data) + def _refresh_state(self, interaction: Interaction, data: Dict[str, Any]) -> None: return None diff --git a/discord/ui/label.py b/discord/ui/label.py index 7a2d496a6..f9313f53f 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -50,6 +50,8 @@ V = TypeVar('V', bound='BaseView', covariant=True) class Label(Item[V]): """Represents a UI label within a modal. + This is a top-level layout component that can only be used on :class:`Modal`. + .. versionadded:: 2.6 Parameters @@ -60,7 +62,7 @@ class Label(Item[V]): 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`] + component: :class:`Item` The component to display below the label. id: Optional[:class:`int`] The ID of the component. This must be unique across the view. @@ -74,8 +76,7 @@ class Label(Item[V]): 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`. + The component to display below the label. """ __item_repr_attributes__: Tuple[str, ...] = ( @@ -138,3 +139,8 @@ class Label(Item[V]): def is_dispatchable(self) -> bool: return False + + @property + def _total_count(self) -> int: + # Count the component and ourselves + return 2 diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 86c09da30..1da93478b 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -36,12 +36,17 @@ from .item import Item from .view import BaseView from .select import BaseSelect from .text_input import TextInput +from ..interactions import Namespace if TYPE_CHECKING: from typing_extensions import Self from ..interactions import Interaction - from ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload + from ..types.interactions import ( + ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload, + ResolvedData as ResolvedDataPayload, + ) + from ..app_commands.namespace import ResolveKey # fmt: off @@ -168,23 +173,41 @@ class Modal(BaseView): """ _log.error('Ignoring exception in modal %r:', self, exc_info=error) - def _refresh(self, interaction: Interaction, components: Sequence[ModalSubmitComponentInteractionDataPayload]) -> None: + def _refresh( + self, + interaction: Interaction, + components: Sequence[ModalSubmitComponentInteractionDataPayload], + resolved: Dict[ResolveKey, Any], + ) -> None: for component in components: if component['type'] == 1: - self._refresh(interaction, component['components']) + self._refresh(interaction, component['components'], resolved) # type: ignore elif component['type'] == 18: - self._refresh(interaction, [component['component']]) + self._refresh(interaction, [component['component']], resolved) # type: ignore else: - item = find(lambda i: getattr(i, 'custom_id', None) == component['custom_id'], self.walk_children()) # type: ignore + custom_id = component.get('custom_id') + if custom_id is None: + continue + + item = find( + lambda i: getattr(i, 'custom_id', None) == custom_id, + self.walk_children(), + ) 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', custom_id) continue - item._refresh_state(interaction, component) # type: ignore - async def _scheduled_task(self, interaction: Interaction, components: List[ModalSubmitComponentInteractionDataPayload]): + item._handle_submit(interaction, component, resolved) # type: ignore + + async def _scheduled_task( + self, + interaction: Interaction, + components: List[ModalSubmitComponentInteractionDataPayload], + resolved: Dict[ResolveKey, Any], + ): try: self._refresh_timeout() - self._refresh(interaction, components) + self._refresh(interaction, components, resolved) allow = await self.interaction_check(interaction) if not allow: @@ -200,7 +223,7 @@ class Modal(BaseView): def to_components(self) -> List[Dict[str, Any]]: def key(item: Item) -> int: - return item._rendered_row or 0 + return item._rendered_row or item.row or 0 children = sorted(self._children, key=key) components: List[Dict[str, Any]] = [] @@ -221,10 +244,18 @@ class Modal(BaseView): return components def _dispatch_submit( - self, interaction: Interaction, components: List[ModalSubmitComponentInteractionDataPayload] + self, + interaction: Interaction, + components: List[ModalSubmitComponentInteractionDataPayload], + resolved: ResolvedDataPayload, ) -> asyncio.Task[None]: + try: + namespace = Namespace._get_resolved_items(interaction, resolved) + except KeyError: + namespace = {} + return asyncio.create_task( - self._scheduled_task(interaction, components), name=f'discord-ui-modal-dispatch-{self.id}' + self._scheduled_task(interaction, components, namespace), name=f'discord-ui-modal-dispatch-{self.id}' ) def to_dict(self) -> Dict[str, Any]: diff --git a/discord/ui/radio.py b/discord/ui/radio.py new file mode 100644 index 000000000..4c02c6638 --- /dev/null +++ b/discord/ui/radio.py @@ -0,0 +1,246 @@ +""" +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, List, Literal, Optional, Tuple, TypeVar, Dict + +import os + +from ..utils import MISSING +from ..components import RadioGroupComponent, RadioGroupOption +from ..enums import ComponentType +from .item import Item + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..interactions import Interaction + from ..types.interactions import ( + ModalSubmitRadioGroupInteractionData as ModalSubmitRadioGroupInteractionDataPayload, + ) + from ..types.components import RadioGroupComponent as RadioGroupComponentPayload + from .view import BaseView + from ..app_commands.namespace import ResolveKey + + +# fmt: off +__all__ = ( + 'RadioGroup', +) +# fmt: on + +V = TypeVar('V', bound='BaseView', covariant=True) + + +class RadioGroup(Item[V]): + """Represents a radio group component within a modal. + + .. versionadded:: 2.7 + + Parameters + ------------ + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + custom_id: Optional[:class:`str`] + The custom ID of the component. + options: List[:class:`discord.RadioGroupOption`] + A list of options that can be selected in this radio group. + Can contain between 2 and 10 items. + required: :class:`bool` + Whether this component is required to be filled before submitting the modal. + Defaults to ``True``. + """ + + __item_repr_attributes__: Tuple[str, ...] = ( + 'id', + 'custom_id', + 'options', + 'required', + ) + + def __init__( + self, + *, + custom_id: str = MISSING, + required: bool = True, + options: List[RadioGroupOption] = MISSING, + id: Optional[int] = None, + ) -> None: + super().__init__() + self._provided_custom_id = custom_id is not MISSING + custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id + if not isinstance(custom_id, str): + raise TypeError(f'expected custom_id to be str not {custom_id.__class__.__name__}') + + self._underlying: RadioGroupComponent = RadioGroupComponent._raw_construct( + id=id, + custom_id=custom_id, + required=required, + options=options or [], + ) + self.id = id + self._value: Optional[str] = None + + @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 + + @property + def value(self) -> Optional[str]: + """Optional[:class:`str`]: The value have been selected by the user, if any.""" + return self._value + + @property + def custom_id(self) -> str: + """:class:`str`: The ID of the component that gets received during an interaction.""" + return self._underlying.custom_id + + @custom_id.setter + def custom_id(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError('custom_id must be a str') + + self._underlying.custom_id = value + self._provided_custom_id = True + + @property + def type(self) -> Literal[ComponentType.radio_group]: + """:class:`.ComponentType`: The type of this component.""" + return ComponentType.radio_group + + @property + def options(self) -> List[RadioGroupOption]: + """List[:class:`discord.RadioGroupOption`]: A list of options that can be selected in this radio group.""" + return self._underlying.options + + @options.setter + def options(self, value: List[RadioGroupOption]) -> None: + if not isinstance(value, list) or not all(isinstance(obj, RadioGroupOption) for obj in value): + raise TypeError('options must be a list of RadioGroupOption') + + self._underlying.options = value + + def add_option( + self, + *, + label: str, + value: str = MISSING, + description: Optional[str] = None, + default: bool = False, + ) -> None: + """Adds an option to the group. + + To append a pre-existing :class:`discord.RadioGroupOption` use the + :meth:`append_option` method instead. + + Parameters + ----------- + label: :class:`str` + The label of the option. This is displayed to users. + Can only be up to 100 characters. + value: :class:`str` + The value of the option. This is not displayed to users. + If not given, defaults to the label. + Can only be up to 100 characters. + description: Optional[:class:`str`] + An additional description of the option, if any. + Can only be up to 100 characters. + default: :class:`bool` + Whether this option is selected by default. + + Raises + ------- + ValueError + The number of options exceeds 10. + """ + + option = RadioGroupOption( + label=label, + value=value, + description=description, + default=default, + ) + + self.append_option(option) + + def append_option(self, option: RadioGroupOption) -> None: + """Appends an option to the group. + + Parameters + ----------- + option: :class:`discord.RadioGroupOption` + The option to append to the group. + + Raises + ------- + ValueError + The number of options exceeds 10. + """ + + if len(self._underlying.options) >= 10: + raise ValueError('maximum number of options already provided (10)') + + self._underlying.options.append(option) + + @property + def required(self) -> bool: + """:class:`bool`: Whether the component is required or not.""" + return self._underlying.required + + @required.setter + def required(self, value: bool) -> None: + self._underlying.required = bool(value) + + @property + def width(self) -> int: + return 5 + + def to_component_dict(self) -> RadioGroupComponentPayload: + return self._underlying.to_dict() + + def _refresh_component(self, component: RadioGroupComponent) -> None: + self._underlying = component + + def _handle_submit( + self, interaction: Interaction, data: ModalSubmitRadioGroupInteractionDataPayload, resolved: Dict[ResolveKey, Any] + ) -> None: + self._value = data.get('value') + + @classmethod + def from_component(cls, component: RadioGroupComponent) -> Self: + self = cls( + id=component.id, + custom_id=component.custom_id, + options=component.options, + required=component.required, + ) + return self + + def is_dispatchable(self) -> bool: + return False diff --git a/discord/ui/section.py b/discord/ui/section.py index 67d35e001..ff687e0be 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -55,7 +55,7 @@ class Section(Item[V]): \*children: Union[:class:`str`, :class:`TextDisplay`] The text displays of this section. Up to 3. accessory: :class:`Item` - The section accessory. + The section accessory. This is usually either a :class:`Button` or :class:`Thumbnail`. id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ @@ -117,6 +117,7 @@ class Section(Item[V]): if not isinstance(value, Item): raise TypeError(f'Expected an Item, got {value.__class__.__name__!r} instead') + value._update_view(self.view) value._parent = self self._accessory = value diff --git a/discord/ui/select.py b/discord/ui/select.py index a181357b7..735c0c34a 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -39,14 +39,14 @@ from typing import ( Sequence, ) from contextvars import ContextVar -import inspect +import copy import os -from .item import Item, ContainedItemCallbackType as ItemCallbackType +from .item import Item, ContainedItemCallbackType as ItemCallbackType, _ItemCallback from ..enums import ChannelType, ComponentType, SelectDefaultValueType from ..partial_emoji import PartialEmoji from ..emoji import Emoji -from ..utils import MISSING, _human_join +from ..utils import MISSING, _human_join, _iscoroutinefunction from ..components import ( SelectOption, SelectMenu, @@ -70,7 +70,7 @@ __all__ = ( ) if TYPE_CHECKING: - from typing_extensions import TypeAlias, TypeGuard + from typing_extensions import TypeAlias, TypeGuard, Self from .view import BaseView from .action_row import ActionRow @@ -78,6 +78,7 @@ if TYPE_CHECKING: from ..types.interactions import SelectMessageComponentInteractionData from ..app_commands import AppCommandChannel, AppCommandThread from ..interactions import Interaction + from ..app_commands.namespace import ResolveKey ValidSelectType: TypeAlias = Literal[ ComponentType.string_select, @@ -239,7 +240,7 @@ class BaseSelect(Item[V]): min_values: Optional[int] = None, max_values: Optional[int] = None, disabled: bool = False, - required: bool = False, + required: bool = True, options: List[SelectOption] = MISSING, channel_types: List[ChannelType] = MISSING, default_values: Sequence[SelectDefaultValue] = MISSING, @@ -268,6 +269,14 @@ class BaseSelect(Item[V]): self.row = row self._values: List[PossibleValue] = [] + def copy(self) -> Self: + new = copy.copy(self) + if isinstance(new.callback, _ItemCallback): + new.callback.item = new + new._parent = self._parent + new._update_view(self.view) + return new + @property def id(self) -> Optional[int]: """Optional[:class:`int`]: The ID of this select.""" @@ -356,7 +365,24 @@ class BaseSelect(Item[V]): def _refresh_component(self, component: SelectMenu) -> None: self._underlying = component - def _refresh_state(self, interaction: Interaction, data: SelectMessageComponentInteractionData) -> None: + def _handle_submit( + self, interaction: Interaction, data: SelectMessageComponentInteractionData, resolved: Dict[ResolveKey, Any] + ) -> None: + payload: List[PossibleValue] + values = selected_values.get({}) + string_values = data.get('values', []) + payload = [v for k, v in resolved.items() if k.id in string_values] + if not payload: + payload = list(string_values) + + self._values = values[self.custom_id] = payload + selected_values.set(values) + + def _refresh_state( + self, + interaction: Interaction, + data: SelectMessageComponentInteractionData, + ) -> None: values = selected_values.get({}) payload: List[PossibleValue] try: @@ -366,7 +392,7 @@ class BaseSelect(Item[V]): ) payload = list(resolved.values()) except KeyError: - payload = data.get('values', []) # type: ignore + payload = list(data.get('values', [])) self._values = values[self.custom_id] = payload selected_values.set(values) @@ -479,10 +505,8 @@ class Select(BaseSelect[V]): @options.setter def options(self, value: List[SelectOption]) -> None: - if not isinstance(value, list): + if not isinstance(value, list) or not all(isinstance(obj, SelectOption) for obj in value): raise TypeError('options must be a list of SelectOption') - if not all(isinstance(obj, SelectOption) for obj in value): - raise TypeError('all list items must subclass SelectOption') self._underlying.options = value @@ -549,7 +573,7 @@ class Select(BaseSelect[V]): """ if len(self._underlying.options) >= 25: - raise ValueError('maximum number of options already provided') + raise ValueError('maximum number of options already provided (25)') self._underlying.options.append(option) @@ -580,6 +604,10 @@ class UserSelect(BaseSelect[V]): Defaults to 1 and must be between 1 and 25. 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 default_values: Sequence[:class:`~discord.abc.Snowflake`] A list of objects representing the users that should be selected by default. Number of items must be in range of ``min_values`` and ``max_values``. @@ -611,6 +639,7 @@ class UserSelect(BaseSelect[V]): min_values: int = 1, max_values: int = 1, disabled: bool = False, + required: bool = True, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, id: Optional[int] = None, @@ -622,6 +651,7 @@ class UserSelect(BaseSelect[V]): min_values=min_values, max_values=max_values, disabled=disabled, + required=required, row=row, default_values=_handle_select_defaults(default_values, self.type), id=id, @@ -682,6 +712,10 @@ class RoleSelect(BaseSelect[V]): Defaults to 1 and must be between 1 and 25. 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 default_values: Sequence[:class:`~discord.abc.Snowflake`] A list of objects representing the roles that should be selected by default. Number of items must be in range of ``min_values`` and ``max_values``. @@ -713,6 +747,7 @@ class RoleSelect(BaseSelect[V]): min_values: int = 1, max_values: int = 1, disabled: bool = False, + required: bool = True, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, id: Optional[int] = None, @@ -724,6 +759,7 @@ class RoleSelect(BaseSelect[V]): min_values=min_values, max_values=max_values, disabled=disabled, + required=required, row=row, default_values=_handle_select_defaults(default_values, self.type), id=id, @@ -779,6 +815,10 @@ class MentionableSelect(BaseSelect[V]): Defaults to 1 and must be between 1 and 25. 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 default_values: Sequence[:class:`~discord.abc.Snowflake`] A list of objects representing the users/roles that should be selected by default. if :class:`.Object` is passed, then the type must be specified in the constructor. @@ -811,6 +851,7 @@ class MentionableSelect(BaseSelect[V]): min_values: int = 1, max_values: int = 1, disabled: bool = False, + required: bool = True, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, id: Optional[int] = None, @@ -822,6 +863,7 @@ class MentionableSelect(BaseSelect[V]): min_values=min_values, max_values=max_values, disabled=disabled, + required=required, row=row, default_values=_handle_select_defaults(default_values, self.type), id=id, @@ -884,6 +926,10 @@ class ChannelSelect(BaseSelect[V]): Defaults to 1 and must be between 1 and 25. 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 default_values: Sequence[:class:`~discord.abc.Snowflake`] A list of objects representing the channels that should be selected by default. Number of items must be in range of ``min_values`` and ``max_values``. @@ -919,6 +965,7 @@ class ChannelSelect(BaseSelect[V]): min_values: int = 1, max_values: int = 1, disabled: bool = False, + required: bool = True, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, id: Optional[int] = None, @@ -930,6 +977,7 @@ class ChannelSelect(BaseSelect[V]): min_values=min_values, max_values=max_values, disabled=disabled, + required=required, row=row, channel_types=channel_types, default_values=_handle_select_defaults(default_values, self.type), @@ -1160,7 +1208,7 @@ def select( """ def decorator(func: ItemCallbackType[S, BaseSelectT]) -> ItemCallbackType[S, BaseSelectT]: - if not inspect.iscoroutinefunction(func): + if not _iscoroutinefunction(func): raise TypeError('select function must be a coroutine function') callback_cls = getattr(cls, '__origin__', cls) if not issubclass(callback_cls, BaseSelect): diff --git a/discord/ui/separator.py b/discord/ui/separator.py index e6dc61f00..9f34341da 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -83,6 +83,15 @@ class Separator(Item[V]): def _is_v2(self): return True + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this separator.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value + @property def visible(self) -> bool: """:class:`bool`: Whether this separator is visible. diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index b6f908748..4abff1a18 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -43,7 +43,8 @@ __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`. + This is a top-level layout component that can only be used on :class:`LayoutView`, + :class:`Section`, :class:`Container`, or :class:`Modal`. .. versionadded:: 2.6 diff --git a/discord/ui/text_input.py b/discord/ui/text_input.py index de0c8e079..09342dfc5 100644 --- a/discord/ui/text_input.py +++ b/discord/ui/text_input.py @@ -53,6 +53,8 @@ V = TypeVar('V', bound='BaseView', covariant=True) class TextInput(Item[V]): """Represents a UI text input. + This a top-level layout component that can only be used in :class:`Label`. + .. container:: operations .. describe:: str(x) @@ -144,11 +146,19 @@ class TextInput(Item[V]): id=id, ) self.row = row - self.id = id def __str__(self) -> str: return self.value + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this text input.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value + @property def custom_id(self) -> str: """:class:`str`: The ID of the text input that gets received during an interaction.""" diff --git a/discord/ui/view.py b/discord/ui/view.py index 9c7547e60..582c619d7 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -28,7 +28,6 @@ from typing import ( Any, Callable, ClassVar, - Coroutine, Dict, Generator, Iterator, @@ -50,7 +49,7 @@ import sys import time import os -from .item import Item, ItemCallbackType +from .item import Item, ItemCallbackType, _ItemCallback from .select import Select from .dynamic import DynamicItem from ..components import ( @@ -83,9 +82,13 @@ if TYPE_CHECKING: import re from ..interactions import Interaction + from .._types import ClientT from ..message import Message from ..types.components import ComponentBase as ComponentBasePayload - from ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload + from ..types.interactions import ( + ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload, + ResolvedData as ResolvedDataPayload, + ) from ..state import ConnectionState from .modal import Modal @@ -204,16 +207,22 @@ class _ViewWeights: self.weights = [0, 0, 0, 0, 0] -class _ViewCallback: - __slots__ = ('view', 'callback', 'item') +class _ViewCacheSnapshot: + __slots__ = ('items', 'dynamic_items') + + def __init__(self) -> None: + self.items: Set[Tuple[int, str]] = set() + self.dynamic_items: Set[re.Pattern[str]] = set() - def __init__(self, callback: ItemCallbackType[Any, Any], view: BaseView, item: Item[BaseView]) -> None: - self.callback: ItemCallbackType[Any, Any] = callback - self.view: BaseView = view - self.item: Item[BaseView] = item + @classmethod + def diff(cls, older: _ViewCacheSnapshot, newer: _ViewCacheSnapshot) -> Self: + self = cls() + self.items = older.items - newer.items + self.dynamic_items = older.dynamic_items - newer.dynamic_items + return self - def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: - return self.callback(self.view, interaction, self.item) + def __repr__(self) -> str: + return f'<_ViewCacheSnapshot items={self.items!r} dynamic_items={self.dynamic_items!r}>' class BaseView: @@ -229,7 +238,15 @@ class BaseView: 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.__snapshot: Optional[_ViewCacheSnapshot] = None + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + self.__stopped: Optional[asyncio.Future[bool]] = None + else: + self.__stopped: Optional[asyncio.Future[bool]] = loop.create_future() + self._total_children: int = len(tuple(self.walk_children())) def _is_layout(self) -> bool: @@ -249,13 +266,13 @@ class BaseView: item._update_view(self) parent = getattr(item, '__discord_ui_parent__', None) if parent and parent._view is None: - parent._view = self + parent._update_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 + item.callback = _ItemCallback(raw, self, item) # type: ignore + item._update_view(self) if isinstance(item, Select): item.options = [option.copy() for option in item.options] setattr(self, raw.__name__, item) @@ -328,6 +345,31 @@ class BaseView: def _add_count(self, value: int) -> None: self._total_children = max(0, self._total_children + value) + @property + def _snapshot(self) -> Optional[_ViewCacheSnapshot]: + return self.__snapshot + + def _get_snapshot_diff(self) -> Optional[_ViewCacheSnapshot]: + if self.__snapshot is None: + self.__snapshot = self._get_snapshot() + return None + + newer = self._get_snapshot() + diff = _ViewCacheSnapshot.diff(older=self.__snapshot, newer=newer) + # Update our snapshot to the newer version after diffing it + self.__snapshot = newer + return diff + + def _get_snapshot(self) -> _ViewCacheSnapshot: + snapshot = _ViewCacheSnapshot() + for item in self.walk_children(): + if isinstance(item, DynamicItem): + snapshot.dynamic_items.add(item.__discord_ui_compiled_template__) + elif item.is_dispatchable(): + custom_id = item.custom_id # type: ignore + snapshot.items.add((item.type.value, custom_id)) + return snapshot + @property def children(self) -> List[Item[Self]]: """List[:class:`Item`]: The list of children attached to this view.""" @@ -449,6 +491,7 @@ class BaseView: pass else: self._add_count(-item._total_count) + item._update_view(None) return self @@ -458,6 +501,9 @@ class BaseView: This function returns the class instance to allow for fluent-style chaining. """ + for child in self._children: + child._update_view(None) + self._children.clear() self._total_children = 0 return self @@ -484,7 +530,7 @@ class BaseView: """ return _utils_get(self.walk_children(), id=id) - async def interaction_check(self, interaction: Interaction, /) -> bool: + async def interaction_check(self, interaction: Interaction[ClientT], /) -> bool: """|coro| A callback that is called when an interaction happens within the view @@ -519,7 +565,7 @@ class BaseView: """ pass - async def on_error(self, interaction: Interaction, error: Exception, item: Item[Any], /) -> None: + async def on_error(self, interaction: Interaction[ClientT], error: Exception, item: Item[Any], /) -> None: """|coro| A callback that is called when an item's callback or :meth:`interaction_check` @@ -538,7 +584,7 @@ class BaseView: """ _log.error('Ignoring exception in view %r for item %r', self, item, exc_info=error) - async def _scheduled_task(self, item: Item, interaction: Interaction): + async def _scheduled_task(self, item: Item[Any], interaction: Interaction[ClientT]): try: item._refresh_state(interaction, interaction.data) # type: ignore @@ -563,7 +609,7 @@ class BaseView: self.__timeout_task = asyncio.create_task(self.__timeout_task_impl()) def _dispatch_timeout(self): - if self.__stopped.done(): + if self.__stopped is None or self.__stopped.done(): return if self.__cancel_callback: @@ -573,9 +619,9 @@ class BaseView: 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) -> Optional[asyncio.Task[None]]: - if self.__stopped.done(): - return + def _dispatch_item(self, item: Item[Any], interaction: Interaction[ClientT]) -> Optional[asyncio.Task[None]]: + if self.__stopped is None or self.__stopped.done(): + return None return asyncio.create_task(self._scheduled_task(item, interaction), name=f'discord-ui-view-dispatch-{self.id}') @@ -606,7 +652,7 @@ class BaseView: This operation cannot be undone. """ - if not self.__stopped.done(): + if self.__stopped is not None and not self.__stopped.done(): self.__stopped.set_result(False) self.__timeout_expiry = None @@ -620,6 +666,9 @@ class BaseView: def is_finished(self) -> bool: """:class:`bool`: Whether the view has finished interacting.""" + if self.__stopped is None: + return False + return self.__stopped.done() def is_dispatching(self) -> bool: @@ -648,6 +697,9 @@ class BaseView: If ``True``, then the view timed out. If ``False`` then the view finished normally. """ + if self.__stopped is None: + self.__stopped = asyncio.get_running_loop().create_future() + return await self.__stopped def walk_children(self) -> Generator[Item[Any], None, None]: @@ -754,6 +806,8 @@ class View(BaseView): pass else: self.__weights.remove_item(item) + item._update_view(None) + return self def clear_items(self) -> Self: @@ -889,8 +943,9 @@ class ViewStore: self._modals[view.custom_id] = view # type: ignore return - dispatch_info = self._views.setdefault(message_id, {}) + dispatch_info = self._views.get(message_id, {}) is_fully_dynamic = True + snapshot = view._get_snapshot_diff() for item in view.walk_children(): if isinstance(item, DynamicItem): pattern = item.__discord_ui_compiled_template__ @@ -899,26 +954,34 @@ class ViewStore: dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore is_fully_dynamic = False + if snapshot is not None: + for key in snapshot.items: + dispatch_info.pop(key, None) + for key in snapshot.dynamic_items: + self._dynamic_items.pop(key, None) + view._cache_key = message_id + if dispatch_info: + self._views[message_id] = dispatch_info + if message_id is not None and not is_fully_dynamic: self._synced_message_views[message_id] = view - def remove_view(self, view: View) -> None: + def remove_view(self, view: BaseView) -> None: if view.__discord_ui_modal__: self._modals.pop(view.custom_id, None) # type: ignore return dispatch_info = self._views.get(view._cache_key) - if dispatch_info: - for item in view._children: - if isinstance(item, DynamicItem): - pattern = item.__discord_ui_compiled_template__ - self._dynamic_items.pop(pattern, None) - elif item.is_dispatchable(): - dispatch_info.pop((item.type.value, item.custom_id), None) # type: ignore + snapshot = view._snapshot + if dispatch_info and snapshot: + for key in snapshot.items: + dispatch_info.pop(key, None) + for key in snapshot.dynamic_items: + self._dynamic_items.pop(key, None) - if len(dispatch_info) == 0: - self._views.pop(view._cache_key, None) + if dispatch_info is not None and len(dispatch_info) == 0: + self._views.pop(view._cache_key, None) self._synced_message_views.pop(view._cache_key, None) # type: ignore @@ -926,7 +989,7 @@ class ViewStore: self, component_type: int, factory: Type[DynamicItem[Item[Any]]], - interaction: Interaction, + interaction: Interaction[ClientT], custom_id: str, match: re.Match[str], ) -> None: @@ -977,7 +1040,7 @@ class ViewStore: except Exception: _log.exception('Ignoring exception in dynamic item callback for %r', item) - def dispatch_dynamic_items(self, component_type: int, custom_id: str, interaction: Interaction) -> None: + def dispatch_dynamic_items(self, component_type: int, custom_id: str, interaction: Interaction[ClientT]) -> None: for pattern, item in self._dynamic_items.items(): match = pattern.fullmatch(custom_id) if match is not None: @@ -988,17 +1051,14 @@ class ViewStore: ) ) - def dispatch_view(self, component_type: int, custom_id: str, interaction: Interaction) -> None: + def dispatch_view(self, component_type: int, custom_id: str, interaction: Interaction[ClientT]) -> None: self.dispatch_dynamic_items(component_type, custom_id, interaction) - interaction_id: Optional[int] = None message_id: Optional[int] = None # Realistically, in a component based interaction the Interaction.message will never be None # However, this guard is just in case Discord screws up somehow msg = interaction.message if msg is not None: message_id = msg.id - if msg.interaction_metadata: - interaction_id = msg.interaction_metadata.id key = (component_type, custom_id) @@ -1007,21 +1067,6 @@ class ViewStore: if message_id is not None: item = self._views.get(message_id, {}).get(key) - if item is None and interaction_id is not None: - try: - items = self._views.pop(interaction_id) - except KeyError: - item = None - else: - item = items.get(key) - # If we actually got the items, then these keys should probably be moved - # to the proper message_id instead of the interaction_id as they are now. - # An interaction_id is only used as a temporary stop gap for - # InteractionResponse.send_message so multiple view instances do not - # override each other. - # NOTE: Fix this mess if /callback endpoint ever gets proper return types - self._views.setdefault(message_id, {}).update(items) - if item is None: # Fallback to None message_id searches in case a persistent view # was added without an associated message_id @@ -1031,28 +1076,27 @@ class ViewStore: if item is None: return - # Note, at this point the View is *not* None - task = item.view._dispatch_item(item, interaction) # type: ignore + if item.view is None: + _log.warning('View interaction referencing unknown view for item %s. Discarding', item) + return + + task = item.view._dispatch_item(item, interaction) if task is not None: self.add_task(task) def dispatch_modal( self, custom_id: str, - interaction: Interaction, + interaction: Interaction[ClientT], components: List[ModalSubmitComponentInteractionDataPayload], + resolved: ResolvedDataPayload, ) -> None: modal = self._modals.get(custom_id) if modal is None: _log.debug('Modal interaction referencing unknown custom_id %s. Discarding', custom_id) return - 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 - self._views.pop(interaction_id, None) - self._synced_message_views.pop(interaction_id, None) + self.add_task(modal._dispatch_submit(interaction, components, resolved)) def is_message_tracked(self, message_id: int) -> bool: return message_id in self._synced_message_views diff --git a/discord/utils.py b/discord/utils.py index dc90c8b95..cb826d6d2 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -26,6 +26,7 @@ from __future__ import annotations import array import asyncio +import inspect from textwrap import TextWrapper from typing import ( Any, @@ -56,6 +57,8 @@ from typing import ( TYPE_CHECKING, ) import unicodedata +import collections.abc +from itertools import islice from base64 import b64encode, b64decode from bisect import bisect_left import datetime @@ -71,7 +74,6 @@ import types import typing import warnings import logging -import zlib import yarl @@ -82,12 +84,20 @@ except ModuleNotFoundError: else: HAS_ORJSON = True +_ZSTD_SOURCE: Literal['zstandard', 'compression.zstd'] | None = None + try: - import zstandard # type: ignore + from zstandard import ZstdDecompressor # type: ignore + + _ZSTD_SOURCE = 'zstandard' except ImportError: - _HAS_ZSTD = False -else: - _HAS_ZSTD = True + try: + from compression.zstd import ZstdDecompressor # type: ignore + + _ZSTD_SOURCE = 'compression.zstd' + except ImportError: + import zlib + __all__ = ( 'oauth_url', @@ -109,6 +119,7 @@ __all__ = ( DISCORD_EPOCH = 1420070400000 DEFAULT_FILE_SIZE_LIMIT_BYTES = 10485760 +TIMESTAMP_PATTERN: re.Pattern[str] = re.compile(r'') class _MissingSentinel: @@ -427,7 +438,7 @@ def time_snowflake(dt: datetime.datetime, /, *, high: bool = False) -> int: def _find(predicate: Callable[[T], Any], iterable: Iterable[T], /) -> Optional[T]: - return next((element for element in iterable if predicate(element)), None) + return next(filter(predicate, iterable), None) async def _afind(predicate: Callable[[T], Any], iterable: AsyncIterable[T], /) -> Optional[T]: @@ -1030,17 +1041,18 @@ def escape_mentions(text: str) -> str: def _chunk(iterator: Iterable[T], max_size: int) -> Iterator[List[T]]: - ret = [] - n = 0 - for item in iterator: - ret.append(item) - n += 1 - if n == max_size: - yield ret - ret = [] - n = 0 - if ret: - yield ret + # Specialise iterators that can be sliced as it is much faster + if isinstance(iterator, collections.abc.Sequence): + for i in range(0, len(iterator), max_size): + yield list(iterator[i : i + max_size]) + else: + # Fallback to slower path + iterator = iter(iterator) + while True: + batch = list(islice(iterator, max_size)) + if not batch: + break + yield batch async def _achunk(iterator: AsyncIterable[T], max_size: int) -> AsyncIterator[List[T]]: @@ -1217,7 +1229,7 @@ def is_inside_class(func: Callable[..., Any]) -> bool: return not remaining.endswith('') -TimestampStyle = Literal['f', 'F', 'd', 'D', 't', 'T', 'R'] +TimestampStyle = Literal['f', 'F', 'd', 'D', 't', 'T', 's', 'S', 'R'] def format_dt(dt: datetime.datetime, /, style: Optional[TimestampStyle] = None) -> str: @@ -1225,23 +1237,27 @@ def format_dt(dt: datetime.datetime, /, style: Optional[TimestampStyle] = None) This allows for a locale-independent way of presenting data using Discord specific Markdown. - +-------------+----------------------------+-----------------+ - | Style | Example Output | Description | - +=============+============================+=================+ - | t | 22:57 | Short Time | - +-------------+----------------------------+-----------------+ - | T | 22:57:58 | Long Time | - +-------------+----------------------------+-----------------+ - | d | 17/05/2016 | Short Date | - +-------------+----------------------------+-----------------+ - | D | 17 May 2016 | Long Date | - +-------------+----------------------------+-----------------+ - | f (default) | 17 May 2016 22:57 | Short Date Time | - +-------------+----------------------------+-----------------+ - | F | Tuesday, 17 May 2016 22:57 | Long Date Time | - +-------------+----------------------------+-----------------+ - | R | 5 years ago | Relative Time | - +-------------+----------------------------+-----------------+ + +-------------+--------------------------------+-------------------------+ + | Style | Example Output | Description | + +=============+================================+=========================+ + | t | 22:57 | Short Time | + +-------------+--------------------------------+-------------------------+ + | T | 22:57:58 | Medium Time | + +-------------+--------------------------------+-------------------------+ + | d | 17/05/2016 | Short Date | + +-------------+--------------------------------+-------------------------+ + | D | May 17, 2016 | Long Date | + +-------------+--------------------------------+-------------------------+ + | f (default) | May 17, 2016 at 22:57 | Long Date, Short Time | + +-------------+--------------------------------+-------------------------+ + | F | Tuesday, May 17, 2016 at 22:57 | Full Date, Short Time | + +-------------+--------------------------------+-------------------------+ + | s | 17/05/2016, 22:57 | Short Date, Short Time | + +-------------+--------------------------------+-------------------------+ + | S | 17/05/2016, 22:57:58 | Short Date, Medium Time | + +-------------+--------------------------------+-------------------------+ + | R | 5 years ago | Relative Time | + +-------------+--------------------------------+-------------------------+ Note that the exact output depends on the user's locale setting in the client. The example output presented is using the ``en-GB`` locale. @@ -1422,20 +1438,25 @@ def _human_join(seq: Sequence[str], /, *, delimiter: str = ', ', final: str = 'o return delimiter.join(seq[:-1]) + f' {final} {seq[-1]}' -if _HAS_ZSTD: +if _ZSTD_SOURCE is not None: class _ZstdDecompressionContext: - __slots__ = ('context',) + __slots__ = ('decompressor',) COMPRESSION_TYPE: str = 'zstd-stream' def __init__(self) -> None: - decompressor = zstandard.ZstdDecompressor() - self.context = decompressor.decompressobj() + self.decompressor = ZstdDecompressor() + if _ZSTD_SOURCE == 'zstandard': + # The default API for zstandard requires a size hint when + # the size is not included in the zstandard frame. + # This constructs an instance of zstandard.ZstdDecompressionObj + # which dynamically allocates a buffer, matching stdlib module's behavior. + self.decompressor = self.decompressor.decompressobj() def decompress(self, data: bytes, /) -> str | None: # Each WS message is a complete gateway message - return self.context.decompress(data).decode('utf-8') + return self.decompressor.decompress(data).decode('utf-8') _ActiveDecompressionContext: Type[_DecompressionContext] = _ZstdDecompressionContext else: @@ -1522,3 +1543,11 @@ class _RawReprMixin: def __repr__(self) -> str: value = ' '.join(f'{attr}={getattr(self, attr)!r}' for attr in self.__slots__) return f'<{self.__class__.__name__} {value}>' + + +# `inspect.iscoroutinefunction()` only became equivalent to (now deprecated) `asyncio.iscoroutinefunction()` in Python 3.12 +# https://github.com/python/cpython/issues/122858#issuecomment-2466239748 +if sys.version_info >= (3, 12): + _iscoroutinefunction = inspect.iscoroutinefunction +else: + _iscoroutinefunction = asyncio.iscoroutinefunction diff --git a/discord/voice_client.py b/discord/voice_client.py index b0f3e951b..f138a8c8a 100644 --- a/discord/voice_client.py +++ b/discord/voice_client.py @@ -34,7 +34,7 @@ from .gateway import * from .errors import ClientException from .player import AudioPlayer, AudioSource from .utils import MISSING -from .voice_state import VoiceConnectionState +from .voice_state import VoiceConnectionState, has_dave if TYPE_CHECKING: from .gateway import DiscordVoiceWebSocket @@ -218,6 +218,8 @@ 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') + if not has_dave: + raise RuntimeError('davey library needed in order to use voice') super().__init__(client, channel) state = client._connection @@ -235,6 +237,7 @@ class VoiceClient(VoiceProtocol): self._connection: VoiceConnectionState = self.create_connection_state() warn_nacl: bool = not has_nacl + warn_dave: bool = not has_dave supported_modes: Tuple[SupportedModes, ...] = ( 'aead_xchacha20_poly1305_rtpsize', 'xsalsa20_poly1305_lite', @@ -284,6 +287,17 @@ class VoiceClient(VoiceProtocol): def timeout(self) -> float: return self._connection.timeout + @property + def voice_privacy_code(self) -> Optional[str]: + """:class:`str`: Get the voice privacy code of this E2EE session's group. + + A new privacy code is created and cached each time a new transition is executed. + This can be None if there is no active DAVE session happening. + + .. versionadded:: 2.7 + """ + return self._connection.dave_session.voice_privacy_code if self._connection.dave_session else None + def checked_add(self, attr: str, value: int, limit: int) -> None: val = getattr(self, attr) if val + value > limit: @@ -368,7 +382,12 @@ class VoiceClient(VoiceProtocol): # audio related - def _get_voice_packet(self, data): + def _get_voice_packet(self, data: bytes): + packet = ( + self._connection.dave_session.encrypt_opus(data) + if self._connection.dave_session and self._connection.can_encrypt + else data + ) header = bytearray(12) # Formulate rtp header @@ -379,7 +398,7 @@ class VoiceClient(VoiceProtocol): struct.pack_into('>I', header, 8, self.ssrc) encrypt_packet = getattr(self, '_encrypt_' + self.mode) - return encrypt_packet(header, data) + return encrypt_packet(header, packet) def _encrypt_aead_xchacha20_poly1305_rtpsize(self, header: bytes, data) -> bytes: # Esentially the same as _lite diff --git a/discord/voice_state.py b/discord/voice_state.py index 5e78c7851..04cc11b61 100644 --- a/discord/voice_state.py +++ b/discord/voice_state.py @@ -69,6 +69,14 @@ if TYPE_CHECKING: WebsocketHook = Optional[Callable[[DiscordVoiceWebSocket, Dict[str, Any]], Coroutine[Any, Any, Any]]] SocketReaderCallback = Callable[[bytes], Any] +has_dave: bool + +try: + import davey # type: ignore + + has_dave = True +except ImportError: + has_dave = False __all__ = ('VoiceConnectionState',) @@ -208,6 +216,10 @@ class VoiceConnectionState: self.mode: SupportedModes = MISSING self.socket: socket.socket = MISSING self.ws: DiscordVoiceWebSocket = MISSING + self.dave_session: Optional[davey.DaveSession] = None + self.dave_protocol_version: int = 0 + self.dave_pending_transitions: Dict[int, int] = {} + self.dave_downgraded: bool = False self._state: ConnectionFlowState = ConnectionFlowState.disconnected self._expecting_disconnect: bool = False @@ -252,6 +264,64 @@ class VoiceConnectionState: def self_voice_state(self) -> Optional[VoiceState]: return self.guild.me.voice + @property + def max_dave_protocol_version(self) -> int: + return davey.DAVE_PROTOCOL_VERSION if has_dave else 0 + + @property + def can_encrypt(self) -> bool: + return self.dave_protocol_version != 0 and self.dave_session != None and self.dave_session.ready + + async def reinit_dave_session(self) -> None: + if self.dave_protocol_version > 0: + if not has_dave: + raise RuntimeError('davey library needed in order to use E2EE voice') + if self.dave_session is not None: + self.dave_session.reinit(self.dave_protocol_version, self.user.id, self.voice_client.channel.id) + else: + self.dave_session = davey.DaveSession(self.dave_protocol_version, self.user.id, self.voice_client.channel.id) + + if self.dave_session is not None: + await self.voice_client.ws.send_binary( + DiscordVoiceWebSocket.MLS_KEY_PACKAGE, self.dave_session.get_serialized_key_package() + ) + elif self.dave_session: + self.dave_session.reset() + self.dave_session.set_passthrough_mode(True, 10) + pass + + async def _recover_from_invalid_commit(self, transition_id: int) -> None: + payload = { + 'op': DiscordVoiceWebSocket.MLS_INVALID_COMMIT_WELCOME, + 'd': { + 'transition_id': transition_id, + }, + } + + await self.voice_client.ws.send_as_json(payload) + await self.reinit_dave_session() + + async def _execute_transition(self, transition_id: int) -> None: + _log.debug('Executing transition id %d', transition_id) + if transition_id not in self.dave_pending_transitions: + _log.warning("Received execute transition, but we don't have a pending transition for id %d", transition_id) + return + + old_version = self.dave_protocol_version + self.dave_protocol_version = self.dave_pending_transitions.pop(transition_id) + + if old_version != self.dave_protocol_version and self.dave_protocol_version == 0: + self.dave_downgraded = True + _log.debug('DAVE Session downgraded') + elif transition_id > 0 and self.dave_downgraded: + self.dave_downgraded = False + if self.dave_session: + self.dave_session.set_passthrough_mode(True, 10) + _log.debug('DAVE Session upgraded') + + # In the future, the session should be signaled too, but for now theres just v1 + _log.debug('Transition id %d executed', transition_id) + async def voice_state_update(self, data: GuildVoiceStatePayload) -> None: channel_id = data['channel_id'] diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 9d4fa0da6..5768c7200 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -364,6 +364,7 @@ class AsyncWebhookAdapter: multipart: Optional[List[Dict[str, Any]]] = None, files: Optional[Sequence[File]] = None, thread_id: Optional[int] = None, + with_components: bool = False, ) -> Response[MessagePayload]: route = Route( 'PATCH', @@ -372,7 +373,9 @@ class AsyncWebhookAdapter: webhook_token=token, message_id=message_id, ) - params = None if thread_id is None else {'thread_id': thread_id} + params = {'with_components': int(with_components)} + if thread_id: + params['thread_id'] = thread_id return self.request( route, session=session, @@ -848,7 +851,15 @@ class WebhookMessage(Message): See :meth:`.abc.Messageable.send` for more information. view: Optional[:class:`~discord.ui.View`] The updated view to update this message with. If ``None`` is passed then - the view is removed. + the view is removed. If the webhook is partial or is not managed by the + library, then you can not send interactable components. Otherwise, you + can send views with any type of components. + + .. 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 @@ -1772,7 +1783,7 @@ class Webhook(BaseWebhook): .. versionadded:: 1.4 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. + is not managed by the library, then you can not send interactable components. Otherwise, you can send views with any type of components. .. versionadded:: 2.0 @@ -1857,12 +1868,10 @@ class Webhook(BaseWebhook): if view is not MISSING: if not hasattr(view, '__discord_ui_view__'): - raise TypeError(f'expected view parameter to be of type View not {view.__class__.__name__}') + raise TypeError(f'expected view parameter to be of type View or LayoutView, not {view.__class__.__name__}') if isinstance(self._state, _WebhookState) and view.is_dispatchable(): - raise ValueError( - 'Webhook views with any component other than URL buttons require an associated state with the webhook' - ) + raise ValueError('Webhook views with interactable components require an associated state with the webhook') if ephemeral is True and view.timeout is None and view.is_dispatchable(): view.timeout = 15 * 60.0 @@ -2048,8 +2057,9 @@ class Webhook(BaseWebhook): See :meth:`.abc.Messageable.send` for more information. 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`. + the view is removed. If the webhook is partial or is not managed by the + library, then you can not send interactable components. Otherwise, you + can send views with any type of components. .. note:: @@ -2085,11 +2095,12 @@ class Webhook(BaseWebhook): if self.token is None: raise ValueError('This webhook does not have a token associated with it') - if view is not MISSING: - if isinstance(self._state, _WebhookState): - raise ValueError('This webhook does not have state associated with it') + if view: + if not hasattr(view, '__discord_ui_view__'): + raise TypeError(f'expected view parameter to be of type View or LayoutView, not {view.__class__.__name__}') - self._state.prevent_view_updates_for(message_id) + if isinstance(self._state, _WebhookState) and view.is_dispatchable(): + raise ValueError('Webhook views with interactable components require an associated state with the webhook') previous_mentions: Optional[AllowedMentions] = getattr(self._state, 'allowed_mentions', None) with handle_message_parameters( @@ -2117,6 +2128,7 @@ class Webhook(BaseWebhook): multipart=params.multipart, files=params.files, thread_id=thread_id, + with_components=bool(view), ) message = self._create_message(data, thread=thread) diff --git a/discord/webhook/sync.py b/discord/webhook/sync.py index 1786496fa..b76af8337 100644 --- a/discord/webhook/sync.py +++ b/discord/webhook/sync.py @@ -329,6 +329,7 @@ class WebhookAdapter: multipart: Optional[List[Dict[str, Any]]] = None, files: Optional[Sequence[File]] = None, thread_id: Optional[int] = None, + with_components: bool = False, ) -> MessagePayload: route = Route( 'PATCH', @@ -337,7 +338,9 @@ class WebhookAdapter: webhook_token=token, message_id=message_id, ) - params = None if thread_id is None else {'thread_id': thread_id} + params = {'with_components': int(with_components)} + if thread_id: + params['thread_id'] = thread_id return self.request(route, session, payload=payload, multipart=multipart, files=files, params=params) def delete_webhook_message( @@ -415,6 +418,7 @@ class SyncWebhookMessage(Message): embed: Optional[Embed] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING, allowed_mentions: Optional[AllowedMentions] = None, + view: Optional[BaseView] = MISSING, ) -> SyncWebhookMessage: """Edits the message. @@ -443,6 +447,19 @@ class SyncWebhookMessage(Message): allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. + view: 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`. + + .. 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.7 Raises ------- @@ -451,7 +468,7 @@ class SyncWebhookMessage(Message): Forbidden Edited a message that is not yours. TypeError - You specified both ``embed`` and ``embeds`` + You specified both ``embed`` and ``embeds``. ValueError The length of ``embeds`` was invalid or there was no token associated with this webhook. @@ -469,6 +486,7 @@ class SyncWebhookMessage(Message): attachments=attachments, allowed_mentions=allowed_mentions, thread=self._state._thread, + view=view, ) def add_files(self, *files: File) -> SyncWebhookMessage: @@ -1245,6 +1263,12 @@ class SyncWebhook(BaseWebhook): If you want to edit a webhook message with any component attached to it, check :meth:`WebhookMessage.edit`. + .. 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.6 allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. @@ -1270,6 +1294,13 @@ class SyncWebhook(BaseWebhook): if self.token is None: raise ValueError('This webhook does not have a token associated with it') + if view: + if not hasattr(view, '__discord_ui_view__'): + raise TypeError(f'expected view parameter to be of type View or LayoutView, not {view.__class__.__name__}') + + if view.is_dispatchable(): + raise ValueError('SyncWebhooks can not send interactable components') + previous_mentions: Optional[AllowedMentions] = getattr(self._state, 'allowed_mentions', None) with handle_message_parameters( content=content, @@ -1278,6 +1309,7 @@ class SyncWebhook(BaseWebhook): embeds=embeds, allowed_mentions=allowed_mentions, previous_allowed_mentions=previous_mentions, + view=view, ) as params: thread_id: Optional[int] = None if thread is not MISSING: @@ -1293,6 +1325,7 @@ class SyncWebhook(BaseWebhook): multipart=params.multipart, files=params.files, thread_id=thread_id, + with_components=bool(view), ) return self._create_message(data, thread=thread) diff --git a/docs/_static/style.css b/docs/_static/style.css index 4354344ec..69728a807 100644 --- a/docs/_static/style.css +++ b/docs/_static/style.css @@ -387,7 +387,7 @@ aside { background-color: var(--mobile-nav-background); color: var(--mobile-nav-text); z-index: 2; - max-height: 100vh; + max-height: 100dvh; overflow-y: auto; overscroll-behavior-y: contain; } @@ -1285,7 +1285,7 @@ div.code-block-caption { display: inline-block; position: sticky; top: 1em; - max-height: calc(100vh - 2em); + max-height: calc(100dvh - 2em); max-width: 100%; overflow-y: auto; margin: 1em; diff --git a/docs/api.rst b/docs/api.rst index ab8f4f5ca..5d816087e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1732,6 +1732,15 @@ of :class:`enum.Enum`. Checks if two messages are not equal. + .. method:: is_deletable() + + Checks if the message type is deletable, as some system messages cannot be deleted. + + .. versionadded:: 2.7 + + :return: A boolean denoting if the message type is deletable. + :rtype: :class:`bool` + .. attribute:: default The default message type. This is the same as regular messages. @@ -2824,6 +2833,7 @@ of :class:`enum.Enum`. which was created. Possible attributes for :class:`AuditLogDiff`: + - :attr:`~AuditLogDiff.name` - :attr:`~AuditLogDiff.channel` - :attr:`~AuditLogDiff.description` @@ -2843,6 +2853,7 @@ of :class:`enum.Enum`. which was updated. Possible attributes for :class:`AuditLogDiff`: + - :attr:`~AuditLogDiff.name` - :attr:`~AuditLogDiff.channel` - :attr:`~AuditLogDiff.description` @@ -2862,6 +2873,7 @@ of :class:`enum.Enum`. which was deleted. Possible attributes for :class:`AuditLogDiff`: + - :attr:`~AuditLogDiff.name` - :attr:`~AuditLogDiff.channel` - :attr:`~AuditLogDiff.description` @@ -5441,6 +5453,7 @@ CategoryChannel .. autoclass:: CategoryChannel() :members: :inherited-members: + :exclude-members: category DMChannel ~~~~~~~~~ @@ -6203,6 +6216,8 @@ The following exceptions are thrown by the library. .. autoexception:: MissingApplicationID +.. autoexception:: FFmpegProcessError + .. autoexception:: discord.opus.OpusError .. autoexception:: discord.opus.OpusNotLoaded @@ -6221,6 +6236,7 @@ Exception Hierarchy - :exc:`PrivilegedIntentsRequired` - :exc:`InteractionResponded` - :exc:`MissingApplicationID` + - :exc:`FFmpegProcessError` - :exc:`GatewayNotFound` - :exc:`HTTPException` - :exc:`Forbidden` diff --git a/docs/ext/commands/api.rst b/docs/ext/commands/api.rst index 3da5cae16..7f4dc28c8 100644 --- a/docs/ext/commands/api.rst +++ b/docs/ext/commands/api.rst @@ -536,6 +536,11 @@ Converters .. autoclass:: discord.ext.commands.SoundboardSoundConverter :members: +.. attributetable:: discord.ext.commands.Timestamp + +.. autoclass:: discord.ext.commands.Timestamp + :members: + .. attributetable:: discord.ext.commands.clean_content .. autoclass:: discord.ext.commands.clean_content diff --git a/docs/ext/commands/commands.rst b/docs/ext/commands/commands.rst index 52e57ff4d..c6ef75b30 100644 --- a/docs/ext/commands/commands.rst +++ b/docs/ext/commands/commands.rst @@ -485,7 +485,7 @@ commands in an easy to use manner. typing.Union ^^^^^^^^^^^^^^ -A :data:`typing.Union` is a special type hint that allows for the command to take in any of the specific types instead of +A :obj:`typing.Union` is a special type hint that allows for the command to take in any of the specific types instead of a singular type. For example, given the following: .. code-block:: python3 @@ -502,12 +502,12 @@ The way this works is through a left-to-right order. It first attempts to conver :class:`discord.TextChannel`, and if it fails it tries to convert it to a :class:`discord.Member`. If all converters fail, then a special error is raised, :exc:`~ext.commands.BadUnionArgument`. -Note that any valid converter discussed above can be passed in to the argument list of a :data:`typing.Union`. +Note that any valid converter discussed above can be passed in to the argument list of a :obj:`typing.Union`. typing.Optional ^^^^^^^^^^^^^^^^^ -A :data:`typing.Optional` is a special type hint that allows for "back-referencing" behaviour. If the converter fails to +A :obj:`typing.Optional` is a special type hint that allows for "back-referencing" behaviour. If the converter fails to parse into the specified type, the parser will skip the parameter and then either ``None`` or the specified default will be passed into the parameter instead. The parser will then continue on to the next parameters and converters, if any. @@ -536,7 +536,7 @@ typing.Literal .. versionadded:: 2.0 -A :data:`typing.Literal` is a special type hint that requires the passed parameter to be equal to one of the listed values +A :obj:`typing.Literal` is a special type hint that requires the passed parameter to be equal to one of the listed values after being converted to the same type. For example, given the following: .. code-block:: python3 @@ -550,7 +550,7 @@ after being converted to the same type. For example, given the following: The ``buy_sell`` parameter must be either the literal string ``"buy"`` or ``"sell"`` and ``amount`` must convert to the ``int`` ``1`` or ``2``. If ``buy_sell`` or ``amount`` don't match any value, then a special error is raised, -:exc:`~.ext.commands.BadLiteralArgument`. Any literal values can be mixed and matched within the same :data:`typing.Literal` converter. +:exc:`~.ext.commands.BadLiteralArgument`. Any literal values can be mixed and matched within the same :obj:`typing.Literal` converter. Note that ``typing.Literal[True]`` and ``typing.Literal[False]`` still follow the :class:`bool` converter rules. @@ -559,7 +559,7 @@ typing.Annotated .. versionadded:: 2.0 -A :data:`typing.Annotated` is a special type introduced in Python 3.9 that allows the type checker to see one type, but allows the library to see another type. This is useful for appeasing the type checker for complicated converters. The second parameter of ``Annotated`` must be the converter that the library should use. +A :obj:`typing.Annotated` is a special type introduced in Python 3.9 that allows the type checker to see one type, but allows the library to see another type. This is useful for appeasing the type checker for complicated converters. The second parameter of ``Annotated`` must be the converter that the library should use. For example, given the following: @@ -581,7 +581,7 @@ The type checker will see ``arg`` as a regular :class:`str` but the library will Greedy ^^^^^^^^ -The :class:`~ext.commands.Greedy` converter is a generalisation of the :data:`typing.Optional` converter, except applied +The :class:`~ext.commands.Greedy` converter is a generalisation of the :obj:`typing.Optional` converter, except applied to a list of arguments. In simple terms, this means that it tries to convert as much as it can until it can't convert any further. @@ -606,7 +606,7 @@ The type passed when using this converter depends on the parameter type that it :class:`~ext.commands.Greedy` parameters can also be made optional by specifying an optional value. -When mixed with the :data:`typing.Optional` converter you can provide simple and expressive command invocation syntaxes: +When mixed with the :obj:`typing.Optional` converter you can provide simple and expressive command invocation syntaxes: .. code-block:: python3 @@ -632,16 +632,16 @@ This command can be invoked any of the following ways: .. warning:: - The usage of :class:`~ext.commands.Greedy` and :data:`typing.Optional` are powerful and useful, however as a + The usage of :class:`~ext.commands.Greedy` and :obj:`typing.Optional` are powerful and useful, however as a price, they open you up to some parsing ambiguities that might surprise some people. - For example, a signature expecting a :data:`typing.Optional` of a :class:`discord.Member` followed by a + For example, a signature expecting a :obj:`typing.Optional` of a :class:`discord.Member` followed by a :class:`int` could catch a member named after a number due to the different ways a :class:`~ext.commands.MemberConverter` decides to fetch members. You should take care to not introduce unintended parsing ambiguities in your code. One technique would be to clamp down the expected syntaxes allowed through custom converters or reordering the parameters to minimise clashes. - To help aid with some parsing ambiguities, :class:`str`, ``None``, :data:`typing.Optional` and + To help aid with some parsing ambiguities, :class:`str`, ``None``, :obj:`typing.Optional` and :class:`~ext.commands.Greedy` are forbidden as parameters for the :class:`~ext.commands.Greedy` converter. @@ -663,7 +663,7 @@ Consider the following example: await ctx.send(f'You have uploaded <{attachment.url}>') -When this command is invoked, the user must directly upload a file for the command body to be executed. When combined with the :data:`typing.Optional` converter, the user does not have to provide an attachment. +When this command is invoked, the user must directly upload a file for the command body to be executed. When combined with the :obj:`typing.Optional` converter, the user does not have to provide an attachment. .. code-block:: python3 @@ -809,7 +809,7 @@ In order to customise the flag syntax we also have a few options that can be pas topic: Optional[str] nsfw: Optional[bool] slowmode: Optional[int] - + # Hello there --bold True class Greeting(commands.FlagConverter): text: str = commands.flag(positional=True) diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index b2098128b..a7a015f3a 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -193,6 +193,43 @@ Container :inherited-members: +FileUploadComponent +~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: FileUploadComponent + +.. autoclass:: FileUploadComponent() + :members: + :inherited-members: + +RadioGroupComponent +~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: RadioGroupComponent + +.. autoclass:: RadioGroupComponent() + :members: + :inherited-members: + +CheckboxComponent +~~~~~~~~~~~~~~~~~ + +.. attributetable:: CheckboxComponent + +.. autoclass:: CheckboxComponent() + :members: + :inherited-members: + +CheckboxGroupComponent +~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: CheckboxGroupComponent + +.. autoclass:: CheckboxGroupComponent() + :members: + :inherited-members: + + AppCommand ~~~~~~~~~~~ @@ -320,6 +357,21 @@ MediaGalleryItem .. autoclass:: MediaGalleryItem :members: +RadioGroupOption +~~~~~~~~~~~~~~~~ + +.. attributetable:: RadioGroupOption + +.. autoclass:: RadioGroupOption() + :members: + +CheckboxGroupOption +~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: CheckboxGroupOption + +.. autoclass:: CheckboxGroupOption() + :members: Enumerations ------------- @@ -479,6 +531,30 @@ Enumerations .. versionadded:: 2.6 + .. attribute:: file_upload + + Represents a file upload component, usually in a modal. + + .. versionadded:: 2.7 + + .. attribute:: radio_group + + Represents a radio group component. + + .. versionadded:: 2.7 + + .. attribute:: checkbox_group + + Represents a checkbox group component. + + .. versionadded:: 2.7 + + .. attribute:: checkbox + + Represents a checkbox component. + + .. versionadded:: 2.7 + .. class:: ButtonStyle Represents the style of the button component. @@ -855,6 +931,50 @@ ActionRow :inherited-members: :exclude-members: callback + +FileUpload +~~~~~~~~~~~ + +.. attributetable:: discord.ui.FileUpload + +.. autoclass:: discord.ui.FileUpload + :members: + :inherited-members: + :exclude-members: callback, interaction_check + +RadioGroup +~~~~~~~~~~~ + +.. attributetable:: discord.ui.RadioGroup + +.. autoclass:: discord.ui.RadioGroup + :members: + :inherited-members: + :exclude-members: callback, interaction_check + + +Checkbox +~~~~~~~~~ + +.. attributetable:: discord.ui.Checkbox + +.. autoclass:: discord.ui.Checkbox + :members: + :inherited-members: + :exclude-members: callback, interaction_check + + +CheckboxGroup +~~~~~~~~~~~~~~ + +.. attributetable:: discord.ui.CheckboxGroup + +.. autoclass:: discord.ui.CheckboxGroup + :members: + :inherited-members: + :exclude-members: callback, interaction_check + + .. _discord_app_commands: Application Commands @@ -1049,6 +1169,14 @@ Range .. autoclass:: discord.app_commands.Range :members: +Timestamp +++++++++++ + +.. attributetable:: discord.app_commands.Timestamp + +.. autoclass:: discord.app_commands.Timestamp + :members: + Translations ~~~~~~~~~~~~~ diff --git a/docs/whats_new.rst b/docs/whats_new.rst index c78c44435..cda9eca44 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -11,6 +11,111 @@ Changelog This page keeps a detailed human friendly rendering of what's new and changed in specific versions. +.. _vp2p7p1: + +v2.7.1 +------- + +Bug Fixes +~~~~~~~~~~ + +- Fix memory leak when using :class:`ui.LayoutView` and removing items but those items not being removed from internal cache. +- Fix ``aiohttp`` deprecation warning for websocket timeouts (:issue:`10418`) + +Miscellaneous +~~~~~~~~~~~~~~ + +- Show ``davey`` dependency output in ``python -m discord --version`` to debug DAVE issues +- Raise an error and warn when ``davey`` is not installed and using voice +- Change how views are bound to the internal cache when using interactions + +.. _vp2p7p0: + +v2.7.0 +------- + +New Features +~~~~~~~~~~~~~ + +- Add DAVE protocol support for voice connections (:issue:`10300`) +- Add support for new :class:`ui.Modal` components (:issue:`10390`) + - :class:`CheckboxGroupComponent` corresponds to :class:`ui.CheckboxGroup` + - :class:`CheckboxComponent` corresponds to :class:`ui.Checkbox` + - :class:`RadioGroupComponent` corresponds to :class:`ui.RadioGroup` + - :class:`CheckboxGroupOption` and :class:`RadioGroupOption` allow creating these options + +- Add timestamp converter and transformer for use with new ``@time`` markdown option (:issue:`10388`) + - This is accessible via :class:`app_commands.Timestamp` and :class:`ext.commands.Timestamp` as an annotation + +- Add several new permissions: + - :attr:`Permissions.bypass_slowmode` (:issue:`10350`) + - :attr:`Permissions.set_voice_channel_status` (:issue:`10279`) + - :attr:`Permissions.pin_messages` + +- Add ``client`` parameter to :meth:`PartialEmoji.from_str` (:issue:`10407`) +- Add support for user collectibles accessible via :attr:`User.collectibles` and :attr:`Member.collectibles` (:issue:`10277`) +- Add :meth:`Message.is_forwardable` to check if a message can be forwarded (:issue:`10353`) +- Add support for getting an integration's scopes (:issue:`10352`) +- Add :attr:`Interaction.command_id` and :attr:`Interaction.custom_id` helpers (:issue:`10321`) +- Support new fields in :meth:`Member.edit` (:issue:`10303`) +- Add support for getting role member counts via :meth:`Guild.role_member_counts` +- Add :attr:`MessageType.is_deletable` +- Add ``reason`` keyword argument to :meth:`Client.delete_invite` (:issue:`10318`, :issue:`10340`) +- Add ``silent`` parameter to :meth:`ForumChannel.create_thread` (:issue:`10304`) +- Add support for :attr:`MessageType.emoji_added` (:issue:`10284`) +- Add channel attribute to automod quarantine user AuditLogAction (:issue:`10274`) + +Bug Fixes +~~~~~~~~~~ + +- Fix FFmpeg errors not sent to after callback (:issue:`10387`) +- Fix :meth:`Webhook.edit_message` missing the view parameter (:issue:`10395`, :issue:`10398`) +- Fix :meth:`TextChannel.purge` failing when encountering certain system messages +- Fix :attr:`Message.call` raising an attribute error when accessed (:issue:`10404`) +- Fix certain component IDs not being able to be settable afterwards +- Fix :class:`ui.Modal` not raising when hitting the 5 item limit +- Fix :attr:`ui.Item.row` not being set appropriately when used in a :class:`ui.Modal` (:issue:`10397`) +- Fix ``compression.zstd`` not working as expected when Discord does not send encoding information (:issue:`10344`) +- Fix rare bug where :attr:`Client.latency` was incorrect due to not updating heartbeat state +- Fix overzealous exporting of symbols within an internal ``primary_guild`` module (:issue:`10295`) +- Close websocket when reconnecting websocket during polling (:issue:`10409`) +- Use :meth:`ui.View.walk_children` when removing items from the view cache (:issue:`10402`) +- |commands| Fix flag annotations not working under Python 3.14 +- |commands| Fix decorator order mattering for hybrid commands +- |commands| Fix :meth:`~ext.commands.Context.from_interaction` derived :attr:`Message.type` being incorrect + +Miscellaneous +~~~~~~~~~~~~~~ + +- Allow :class:`ui.View` initialization without a running event loop (:issue:`10367`) +- Optimise :func:`utils.find` and specialise :func:`utils.as_chunks` (:issue:`10351`) +- Detach :attr:`ui.Item.view` when the item is removed (:issue:`10348`) +- Change ``description`` to be optional when creating emoji (:issue:`10346`) +- Don't assume Python 3.14 always has ``compression.zstd`` (:issue:`10328`) +- Use webp as the default emoji URL format +- |tasks| Log handled exceptions before sleeping + + +.. _vp2p6p4: + +v2.6.4 +------- + +Bug Fixes +~~~~~~~~~~ + +- Fix :class:`InviteType` and :class:`ReactionType` not being exported (:issue:`10310`) +- Fix :class:`ui.Modal` submits not working for components without a ``custom_id`` (:issue:`10307`) +- Fix ``required`` keyword argument missing in most :class:`ui.Select` classes (:issue:`10307`) +- Fix incorrect handling of :class:`ui.Modal` submit data when using selects (:issue:`10307`) +- Fix potential exception when assigning :attr:`ui.Container.children` +- Fix :attr:`ui.Section.accessory` setter not updating internal state leading to an exception + +Miscellaneous +~~~~~~~~~~~~~~ + +- Use ``compression.zstd`` from the standard library if available on Python 3.14 (:issue:`10323`) + .. _vp2p6p3: v2.6.3 diff --git a/examples/modals/report.py b/examples/modals/report.py new file mode 100644 index 000000000..9e027a8c1 --- /dev/null +++ b/examples/modals/report.py @@ -0,0 +1,143 @@ +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) +# The ID of the channel where reports will be sent to +REPORTS_CHANNEL_ID = 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: + await self.tree.sync(guild=TEST_GUILD) + + +# Define a modal dialog for reporting issues or feedback +class ReportModal(discord.ui.Modal, title='Your Report'): + topic = discord.ui.Label( + text='Topic', + description='Select the topic of the report.', + component=discord.ui.Select( + placeholder='Choose a topic...', + options=[ + discord.SelectOption(label='Bug', description='Report a bug in the bot'), + discord.SelectOption(label='Feedback', description='Provide feedback or suggestions'), + discord.SelectOption(label='Feature Request', description='Request a new feature'), + discord.SelectOption(label='Performance', description='Report performance issues'), + discord.SelectOption(label='UI/UX', description='Report user interface or experience issues'), + discord.SelectOption(label='Security', description='Report security vulnerabilities'), + discord.SelectOption(label='Other', description='Other types of reports'), + ], + ), + ) + report_title = discord.ui.Label( + text='Title', + description='A short title for the report.', + component=discord.ui.TextInput( + style=discord.TextStyle.short, + placeholder='The bot does not respond to commands', + max_length=120, + ), + ) + description = discord.ui.Label( + text='Description', + description='A detailed description of the report.', + component=discord.ui.TextInput( + style=discord.TextStyle.paragraph, + placeholder='When I use /ping, the bot does not respond at all. There are no error messages.', + max_length=2000, + ), + ) + images = discord.ui.Label( + text='Images', + description='Upload any relevant images for your report (optional).', + component=discord.ui.FileUpload( + max_values=10, + custom_id='report_images', + required=False, + ), + ) + footer = discord.ui.TextDisplay( + 'Please ensure your report follows the server rules. Any kind of abuse will result in a ban.' + ) + + def to_view(self, interaction: discord.Interaction) -> discord.ui.LayoutView: + # Tell the type checker what our components are... + assert isinstance(self.topic.component, discord.ui.Select) + assert isinstance(self.description.component, discord.ui.TextInput) + assert isinstance(self.report_title.component, discord.ui.TextInput) + assert isinstance(self.images.component, discord.ui.FileUpload) + + topic = self.topic.component.values[0] + title = self.report_title.component.value + description = self.description.component.value + files = self.images.component.values + + view = discord.ui.LayoutView() + container = discord.ui.Container() + view.add_item(container) + + container.add_item(discord.ui.TextDisplay(f'-# User Report\n## {topic}')) + + timestamp = discord.utils.format_dt(interaction.created_at, 'F') + footer = discord.ui.TextDisplay(f'-# Reported by {interaction.user} (ID: {interaction.user.id}) | {timestamp}') + + container.add_item(discord.ui.TextDisplay(f'### {title}')) + container.add_item(discord.ui.TextDisplay(f'>>> {description}')) + + if files: + gallery = discord.ui.MediaGallery() + gallery.items = [discord.MediaGalleryItem(media=attachment.url) for attachment in files] + container.add_item(gallery) + + container.add_item(footer) + return view + + async def on_submit(self, interaction: discord.Interaction[MyClient]): + view = self.to_view(interaction) + + # Send the report to the designated channel + reports_channel = interaction.client.get_partial_messageable(REPORTS_CHANNEL_ID) + await reports_channel.send(view=view) + await interaction.response.send_message('Thank you for your report! We will look into it shortly.', 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='Report an issue or provide feedback.') +async def report(interaction: discord.Interaction): + # Send the modal with an instance of our `ReportModal` 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. + await interaction.response.send_modal(ReportModal()) + + +client.run('token') diff --git a/pyproject.toml b/pyproject.toml index 20d117b01..360676812 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,10 @@ Documentation = "https://discordpy.readthedocs.io/en/latest/" dependencies = { file = "requirements.txt" } [project.optional-dependencies] -voice = ["PyNaCl>=1.5.0,<1.6"] +voice = [ + "PyNaCl>=1.6.0,<1.7", + "davey>=0.1.0" +] docs = [ "sphinx==4.4.0", "sphinxcontrib_trio==1.1.2", @@ -58,7 +61,7 @@ speed = [ "aiodns>=1.1; sys_platform != 'win32'", "Brotli", "cchardet==2.1.7; python_version < '3.10'", - "zstandard>=0.23.0" + "zstandard>=0.23.0; python_version <= '3.13'" ] test = [ "coverage[toml]", @@ -86,12 +89,12 @@ packages = [ ] include-package-data = true -[tool.black] -line-length = 125 -skip-string-normalization = true - [tool.ruff] line-length = 125 +extend-exclude = ["docs", "tests"] + +[tool.ruff.lint.isort] +combine-as-imports = true [tool.ruff.format] line-ending = "lf" @@ -110,12 +113,6 @@ exclude_lines = [ "@overload", ] -[tool.isort] -profile = "black" -combine_as_imports = true -combine_star = true -line_length = 125 - [tool.pyright] include = [ "discord",