diff --git a/discord/app_commands/__init__.py b/discord/app_commands/__init__.py index 971461713..a338cab75 100644 --- a/discord/app_commands/__init__.py +++ b/discord/app_commands/__init__.py @@ -16,5 +16,6 @@ from .tree import * from .namespace import * from .transformers import * from .translator import * +from .installs import * from . import checks as checks from .checks import Cooldown as Cooldown diff --git a/discord/app_commands/commands.py b/discord/app_commands/commands.py index 6f46fbe4c..23fe953a1 100644 --- a/discord/app_commands/commands.py +++ b/discord/app_commands/commands.py @@ -49,6 +49,7 @@ import re from copy import copy as shallow_copy from ..enums import AppCommandOptionType, AppCommandType, ChannelType, Locale +from .installs import AppCommandContext, AppInstallationType from .models import Choice from .transformers import annotation_to_parameter, CommandParameter, NoneType from .errors import AppCommandError, CheckFailure, CommandInvokeError, CommandSignatureMismatch, CommandAlreadyRegistered @@ -65,6 +66,8 @@ if TYPE_CHECKING: from ..abc import Snowflake from .namespace import Namespace from .models import ChoiceT + from .tree import CommandTree + from .._types import ClientT # Generally, these two libraries are supposed to be separate from each other. # However, for type hinting purposes it's unfortunately necessary for one to @@ -87,6 +90,12 @@ __all__ = ( 'autocomplete', 'guilds', 'guild_only', + 'dm_only', + 'private_channel_only', + 'allowed_contexts', + 'guild_install', + 'user_install', + 'allowed_installs', 'default_permissions', ) @@ -618,6 +627,16 @@ class Command(Generic[GroupT, P, T]): Whether the command should only be usable in guild contexts. Due to a Discord limitation, this does not work on subcommands. + allowed_contexts: Optional[:class:`~discord.app_commands.AppCommandContext`] + The contexts that the command is allowed to be used in. + Overrides ``guild_only`` if this is set. + + .. versionadded:: 2.4 + allowed_installs: Optional[:class:`~discord.app_commands.AppInstallationType`] + The installation contexts that the command is allowed to be installed + on. + + .. versionadded:: 2.4 nsfw: :class:`bool` Whether the command is NSFW and should only work in NSFW channels. @@ -638,6 +657,8 @@ class Command(Generic[GroupT, P, T]): nsfw: bool = False, parent: Optional[Group] = None, guild_ids: Optional[List[int]] = None, + allowed_contexts: Optional[AppCommandContext] = None, + allowed_installs: Optional[AppInstallationType] = None, auto_locale_strings: bool = True, extras: Dict[Any, Any] = MISSING, ): @@ -672,6 +693,13 @@ class Command(Generic[GroupT, P, T]): callback, '__discord_app_commands_default_permissions__', None ) self.guild_only: bool = getattr(callback, '__discord_app_commands_guild_only__', False) + self.allowed_contexts: Optional[AppCommandContext] = allowed_contexts or getattr( + callback, '__discord_app_commands_contexts__', None + ) + self.allowed_installs: Optional[AppInstallationType] = allowed_installs or getattr( + callback, '__discord_app_commands_installation_types__', None + ) + self.nsfw: bool = nsfw self.extras: Dict[Any, Any] = extras or {} @@ -718,8 +746,8 @@ class Command(Generic[GroupT, P, T]): return copy - async def get_translated_payload(self, translator: Translator) -> Dict[str, Any]: - base = self.to_dict() + async def get_translated_payload(self, tree: CommandTree[ClientT], translator: Translator) -> Dict[str, Any]: + base = self.to_dict(tree) name_localizations: Dict[str, str] = {} description_localizations: Dict[str, str] = {} @@ -745,7 +773,7 @@ class Command(Generic[GroupT, P, T]): ] return base - def to_dict(self) -> Dict[str, Any]: + def to_dict(self, tree: CommandTree[ClientT]) -> Dict[str, Any]: # If we have a parent then our type is a subcommand # Otherwise, the type falls back to the specific command type (e.g. slash command or context menu) option_type = AppCommandType.chat_input.value if self.parent is None else AppCommandOptionType.subcommand.value @@ -760,6 +788,8 @@ class Command(Generic[GroupT, P, T]): base['nsfw'] = self.nsfw base['dm_permission'] = not self.guild_only base['default_member_permissions'] = None if self.default_permissions is None else self.default_permissions.value + base['contexts'] = tree.allowed_contexts._merge_to_array(self.allowed_contexts) + base['integration_types'] = tree.allowed_installs._merge_to_array(self.allowed_installs) return base @@ -1167,6 +1197,16 @@ class ContextMenu: guild_only: :class:`bool` Whether the command should only be usable in guild contexts. Defaults to ``False``. + allowed_contexts: Optional[:class:`~discord.app_commands.AppCommandContext`] + The contexts that this context menu is allowed to be used in. + Overrides ``guild_only`` if set. + + .. versionadded:: 2.4 + allowed_installs: Optional[:class:`~discord.app_commands.AppInstallationType`] + The installation contexts that the command is allowed to be installed + on. + + .. versionadded:: 2.4 nsfw: :class:`bool` Whether the command is NSFW and should only work in NSFW channels. Defaults to ``False``. @@ -1189,6 +1229,8 @@ class ContextMenu: type: AppCommandType = MISSING, nsfw: bool = False, guild_ids: Optional[List[int]] = None, + allowed_contexts: Optional[AppCommandContext] = None, + allowed_installs: Optional[AppInstallationType] = None, auto_locale_strings: bool = True, extras: Dict[Any, Any] = MISSING, ): @@ -1214,6 +1256,12 @@ class ContextMenu: ) self.nsfw: bool = nsfw self.guild_only: bool = getattr(callback, '__discord_app_commands_guild_only__', False) + self.allowed_contexts: Optional[AppCommandContext] = allowed_contexts or getattr( + callback, '__discord_app_commands_contexts__', None + ) + self.allowed_installs: Optional[AppInstallationType] = allowed_installs or getattr( + callback, '__discord_app_commands_installation_types__', None + ) self.checks: List[Check] = getattr(callback, '__discord_app_commands_checks__', []) self.extras: Dict[Any, Any] = extras or {} @@ -1231,8 +1279,8 @@ class ContextMenu: """:class:`str`: Returns the fully qualified command name.""" return self.name - async def get_translated_payload(self, translator: Translator) -> Dict[str, Any]: - base = self.to_dict() + async def get_translated_payload(self, tree: CommandTree[ClientT], translator: Translator) -> Dict[str, Any]: + base = self.to_dict(tree) context = TranslationContext(location=TranslationContextLocation.command_name, data=self) if self._locale_name: name_localizations: Dict[str, str] = {} @@ -1244,11 +1292,13 @@ class ContextMenu: base['name_localizations'] = name_localizations return base - def to_dict(self) -> Dict[str, Any]: + def to_dict(self, tree: CommandTree[ClientT]) -> Dict[str, Any]: return { 'name': self.name, 'type': self.type.value, 'dm_permission': not self.guild_only, + 'contexts': tree.allowed_contexts._merge_to_array(self.allowed_contexts), + 'integration_types': tree.allowed_installs._merge_to_array(self.allowed_installs), 'default_member_permissions': None if self.default_permissions is None else self.default_permissions.value, 'nsfw': self.nsfw, } @@ -1405,6 +1455,16 @@ class Group: Whether the group should only be usable in guild contexts. Due to a Discord limitation, this does not work on subcommands. + allowed_contexts: Optional[:class:`~discord.app_commands.AppCommandContext`] + The contexts that this group is allowed to be used in. Overrides + guild_only if set. + + .. versionadded:: 2.4 + allowed_installs: Optional[:class:`~discord.app_commands.AppInstallationType`] + The installation contexts that the command is allowed to be installed + on. + + .. versionadded:: 2.4 nsfw: :class:`bool` Whether the command is NSFW and should only work in NSFW channels. @@ -1424,6 +1484,8 @@ class Group: __discord_app_commands_group_locale_description__: Optional[locale_str] = None __discord_app_commands_group_nsfw__: bool = False __discord_app_commands_guild_only__: bool = MISSING + __discord_app_commands_contexts__: Optional[AppCommandContext] = MISSING + __discord_app_commands_installation_types__: Optional[AppInstallationType] = MISSING __discord_app_commands_default_permissions__: Optional[Permissions] = MISSING __discord_app_commands_has_module__: bool = False __discord_app_commands_error_handler__: Optional[ @@ -1492,6 +1554,8 @@ class Group: parent: Optional[Group] = None, guild_ids: Optional[List[int]] = None, guild_only: bool = MISSING, + allowed_contexts: Optional[AppCommandContext] = MISSING, + allowed_installs: Optional[AppInstallationType] = MISSING, nsfw: bool = MISSING, auto_locale_strings: bool = True, default_permissions: Optional[Permissions] = MISSING, @@ -1540,6 +1604,22 @@ class Group: self.guild_only: bool = guild_only + if allowed_contexts is MISSING: + if cls.__discord_app_commands_contexts__ is MISSING: + allowed_contexts = None + else: + allowed_contexts = cls.__discord_app_commands_contexts__ + + self.allowed_contexts: Optional[AppCommandContext] = allowed_contexts + + if allowed_installs is MISSING: + if cls.__discord_app_commands_installation_types__ is MISSING: + allowed_installs = None + else: + allowed_installs = cls.__discord_app_commands_installation_types__ + + self.allowed_installs: Optional[AppInstallationType] = allowed_installs + if nsfw is MISSING: nsfw = cls.__discord_app_commands_group_nsfw__ @@ -1633,8 +1713,8 @@ class Group: return copy - async def get_translated_payload(self, translator: Translator) -> Dict[str, Any]: - base = self.to_dict() + async def get_translated_payload(self, tree: CommandTree[ClientT], translator: Translator) -> Dict[str, Any]: + base = self.to_dict(tree) name_localizations: Dict[str, str] = {} description_localizations: Dict[str, str] = {} @@ -1654,10 +1734,10 @@ class Group: base['name_localizations'] = name_localizations base['description_localizations'] = description_localizations - base['options'] = [await child.get_translated_payload(translator) for child in self._children.values()] + base['options'] = [await child.get_translated_payload(tree, translator) for child in self._children.values()] return base - def to_dict(self) -> Dict[str, Any]: + def to_dict(self, tree: CommandTree[ClientT]) -> Dict[str, Any]: # If this has a parent command then it's part of a subcommand group # Otherwise, it's just a regular command option_type = 1 if self.parent is None else AppCommandOptionType.subcommand_group.value @@ -1665,13 +1745,15 @@ class Group: 'name': self.name, 'description': self.description, 'type': option_type, - 'options': [child.to_dict() for child in self._children.values()], + 'options': [child.to_dict(tree) for child in self._children.values()], } if self.parent is None: base['nsfw'] = self.nsfw base['dm_permission'] = not self.guild_only base['default_member_permissions'] = None if self.default_permissions is None else self.default_permissions.value + base['contexts'] = tree.allowed_contexts._merge_to_array(self.allowed_contexts) + base['integration_types'] = tree.allowed_installs._merge_to_array(self.allowed_installs) return base @@ -2421,8 +2503,181 @@ 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 else: f.__discord_app_commands_guild_only__ = True # type: ignore # Runtime attribute assignment + + allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext() + f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment + + allowed_contexts.guild = True + + return f + + # Check if called with parentheses or not + if func is None: + # Called with parentheses + return inner + else: + return inner(func) + + +def private_channel_only(func: Optional[T] = None) -> Union[T, Callable[[T], T]]: + """A decorator that indicates this command can only be used in the context of DMs and group DMs. + + This is **not** implemented as a :func:`check`, and is instead verified by Discord server side. + Therefore, there is no error handler called when a command is used within a guild. + + This decorator can be called with or without parentheses. + + Due to a Discord limitation, this decorator does nothing in subcommands and is ignored. + + Examples + --------- + + .. code-block:: python3 + + @app_commands.command() + @app_commands.private_channel_only() + async def my_private_channel_only_command(interaction: discord.Interaction) -> None: + await interaction.response.send_message('I am only available in DMs and GDMs!') + """ + + 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 + else: + allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext() + f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment + + allowed_contexts.private_channel = True + + return f + + # Check if called with parentheses or not + if func is None: + # Called with parentheses + return inner + else: + return inner(func) + + +def dm_only(func: Optional[T] = None) -> Union[T, Callable[[T], T]]: + """A decorator that indicates this command can only be used in the context of bot DMs. + + This is **not** implemented as a :func:`check`, and is instead verified by Discord server side. + Therefore, there is no error handler called when a command is used within a guild or group DM. + + This decorator can be called with or without parentheses. + + Due to a Discord limitation, this decorator does nothing in subcommands and is ignored. + + Examples + --------- + + .. code-block:: python3 + + @app_commands.command() + @app_commands.dm_only() + async def my_dm_only_command(interaction: discord.Interaction) -> None: + await interaction.response.send_message('I am only available in DMs!') + """ + + 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 + else: + allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext() + f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment + + allowed_contexts.dm_channel = True + return f + + # Check if called with parentheses or not + if func is None: + # Called with parentheses + return inner + else: + return inner(func) + + +def allowed_contexts( + guilds: bool = MISSING, dms: bool = MISSING, private_channels: bool = MISSING +) -> Union[T, Callable[[T], T]]: + """A decorator that indicates this command can only be used in certain contexts. + Valid contexts are guilds, DMs and private channels. + + This is **not** implemented as a :func:`check`, and is instead verified by Discord server side. + + Due to a Discord limitation, this decorator does nothing in subcommands and is ignored. + + Examples + --------- + + .. code-block:: python3 + + @app_commands.command() + @app_commands.allowed_contexts(guilds=True, dms=False, private_channels=True) + async def my_command(interaction: discord.Interaction) -> None: + await interaction.response.send_message('I am only available in guilds and private channels!') + """ + + 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 + else: + allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext() + f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment + + if guilds is not MISSING: + allowed_contexts.guild = guilds + + if dms is not MISSING: + allowed_contexts.dm_channel = dms + + if private_channels is not MISSING: + allowed_contexts.private_channel = private_channels + + return f + + return inner + + +def guild_install(func: Optional[T] = None) -> Union[T, Callable[[T], T]]: + """A decorator that indicates this command should be installed in guilds. + + This is **not** implemented as a :func:`check`, and is instead verified by Discord server side. + + Due to a Discord limitation, this decorator does nothing in subcommands and is ignored. + + Examples + --------- + + .. code-block:: python3 + + @app_commands.command() + @app_commands.guild_install() + async def my_guild_install_command(interaction: discord.Interaction) -> None: + await interaction.response.send_message('I am installed in guilds by default!') + """ + + def inner(f: T) -> T: + if isinstance(f, (Command, Group, ContextMenu)): + allowed_installs = f.allowed_installs or AppInstallationType() + f.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 + + allowed_installs.guild = True + return f # Check if called with parentheses or not @@ -2433,6 +2688,85 @@ def guild_only(func: Optional[T] = None) -> Union[T, Callable[[T], T]]: return inner(func) +def user_install(func: Optional[T] = None) -> Union[T, Callable[[T], T]]: + """A decorator that indicates this command should be installed for users. + + This is **not** implemented as a :func:`check`, and is instead verified by Discord server side. + + Due to a Discord limitation, this decorator does nothing in subcommands and is ignored. + + Examples + --------- + + .. code-block:: python3 + + @app_commands.command() + @app_commands.user_install() + async def my_user_install_command(interaction: discord.Interaction) -> None: + await interaction.response.send_message('I am installed in users by default!') + """ + + def inner(f: T) -> T: + if isinstance(f, (Command, Group, ContextMenu)): + allowed_installs = f.allowed_installs or AppInstallationType() + f.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 + + allowed_installs.user = True + + return f + + # Check if called with parentheses or not + if func is None: + # Called with parentheses + return inner + else: + return inner(func) + + +def allowed_installs( + guilds: bool = MISSING, + users: bool = MISSING, +) -> Union[T, Callable[[T], T]]: + """A decorator that indicates this command should be installed in certain contexts. + Valid contexts are guilds and users. + + This is **not** implemented as a :func:`check`, and is instead verified by Discord server side. + + Due to a Discord limitation, this decorator does nothing in subcommands and is ignored. + + Examples + --------- + + .. code-block:: python3 + + @app_commands.command() + @app_commands.allowed_installs(guilds=False, users=True) + async def my_command(interaction: discord.Interaction) -> None: + await interaction.response.send_message('I am installed in users by default!') + """ + + def inner(f: T) -> T: + if isinstance(f, (Command, Group, ContextMenu)): + allowed_installs = f.allowed_installs or AppInstallationType() + f.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 + + if guilds is not MISSING: + allowed_installs.guild = guilds + + if users is not MISSING: + allowed_installs.user = users + + return f + + return inner + + def default_permissions(**perms: bool) -> Callable[[T], T]: r"""A decorator that sets the default permissions needed to execute this command. diff --git a/discord/app_commands/installs.py b/discord/app_commands/installs.py new file mode 100644 index 000000000..7d9b2f049 --- /dev/null +++ b/discord/app_commands/installs.py @@ -0,0 +1,207 @@ +""" +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, ClassVar, List, Optional, Sequence + +__all__ = ( + 'AppInstallationType', + 'AppCommandContext', +) + +if TYPE_CHECKING: + from typing_extensions import Self + from ..types.interactions import InteractionContextType, InteractionInstallationType + + +class AppInstallationType: + r"""Represents the installation location of an application command. + + .. versionadded:: 2.4 + + Parameters + ----------- + guild: Optional[:class:`bool`] + Whether the integration is a guild install. + user: Optional[:class:`bool`] + Whether the integration is a user install. + """ + + __slots__ = ('_guild', '_user') + + GUILD: ClassVar[int] = 0 + USER: ClassVar[int] = 1 + + def __init__(self, *, guild: Optional[bool] = None, user: Optional[bool] = None): + self._guild: Optional[bool] = guild + self._user: Optional[bool] = user + + @property + def guild(self) -> bool: + """:class:`bool`: Whether the integration is a guild install.""" + return bool(self._guild) + + @guild.setter + def guild(self, value: bool) -> None: + self._guild = bool(value) + + @property + def user(self) -> bool: + """:class:`bool`: Whether the integration is a user install.""" + return bool(self._user) + + @user.setter + def user(self, value: bool) -> None: + self._user = bool(value) + + def merge(self, other: AppInstallationType) -> AppInstallationType: + # Merging is similar to AllowedMentions where `self` is the base + # and the `other` is the override preference + guild = self.guild if other.guild is None else other.guild + user = self.user if other.user is None else other.user + return AppInstallationType(guild=guild, user=user) + + def _is_unset(self) -> bool: + return all(x is None for x in (self._guild, self._user)) + + def _merge_to_array(self, other: Optional[AppInstallationType]) -> Optional[List[InteractionInstallationType]]: + result = self.merge(other) if other is not None else self + if result._is_unset(): + return None + return result.to_array() + + @classmethod + def _from_value(cls, value: Sequence[InteractionInstallationType]) -> Self: + self = cls() + for x in value: + if x == cls.GUILD: + self._guild = True + elif x == cls.USER: + self._user = True + return self + + def to_array(self) -> List[InteractionInstallationType]: + values = [] + if self._guild: + values.append(self.GUILD) + if self._user: + values.append(self.USER) + return values + + +class AppCommandContext: + r"""Wraps up the Discord :class:`~discord.app_commands.Command` execution context. + + .. versionadded:: 2.4 + + Parameters + ----------- + guild: Optional[:class:`bool`] + Whether the context allows usage in a guild. + dm_channel: Optional[:class:`bool`] + Whether the context allows usage in a DM channel. + private_channel: Optional[:class:`bool`] + Whether the context allows usage in a DM or a GDM channel. + """ + + GUILD: ClassVar[int] = 0 + DM_CHANNEL: ClassVar[int] = 1 + PRIVATE_CHANNEL: ClassVar[int] = 2 + + __slots__ = ('_guild', '_dm_channel', '_private_channel') + + def __init__( + self, + *, + guild: Optional[bool] = None, + dm_channel: Optional[bool] = None, + private_channel: Optional[bool] = None, + ): + self._guild: Optional[bool] = guild + self._dm_channel: Optional[bool] = dm_channel + self._private_channel: Optional[bool] = private_channel + + @property + def guild(self) -> bool: + """:class:`bool`: Whether the context allows usage in a guild.""" + return bool(self._guild) + + @guild.setter + def guild(self, value: bool) -> None: + self._guild = bool(value) + + @property + def dm_channel(self) -> bool: + """:class:`bool`: Whether the context allows usage in a DM channel.""" + return bool(self._dm_channel) + + @dm_channel.setter + def dm_channel(self, value: bool) -> None: + self._dm_channel = bool(value) + + @property + def private_channel(self) -> bool: + """:class:`bool`: Whether the context allows usage in a DM or a GDM channel.""" + return bool(self._private_channel) + + @private_channel.setter + def private_channel(self, value: bool) -> None: + self._private_channel = bool(value) + + def merge(self, other: AppCommandContext) -> AppCommandContext: + guild = self.guild if other.guild is None else other.guild + dm_channel = self.dm_channel if other.dm_channel is None else other.dm_channel + private_channel = self.private_channel if other.private_channel is None else other.private_channel + return AppCommandContext(guild=guild, dm_channel=dm_channel, private_channel=private_channel) + + def _is_unset(self) -> bool: + return all(x is None for x in (self._guild, self._dm_channel, self._private_channel)) + + def _merge_to_array(self, other: Optional[AppCommandContext]) -> Optional[List[InteractionContextType]]: + result = self.merge(other) if other is not None else self + if result._is_unset(): + return None + return result.to_array() + + @classmethod + def _from_value(cls, value: Sequence[InteractionContextType]) -> Self: + self = cls() + for x in value: + if x == cls.GUILD: + self._guild = True + elif x == cls.DM_CHANNEL: + self._dm_channel = True + elif x == cls.PRIVATE_CHANNEL: + self._private_channel = True + return self + + def to_array(self) -> List[InteractionContextType]: + values = [] + if self._guild: + values.append(self.GUILD) + if self._dm_channel: + values.append(self.DM_CHANNEL) + if self._private_channel: + values.append(self.PRIVATE_CHANNEL) + return values diff --git a/discord/app_commands/models.py b/discord/app_commands/models.py index 3e9d250b2..e8a96784b 100644 --- a/discord/app_commands/models.py +++ b/discord/app_commands/models.py @@ -26,9 +26,17 @@ from __future__ import annotations from datetime import datetime from .errors import MissingApplicationID +from ..flags import AppCommandContext, AppInstallationType from .translator import TranslationContextLocation, TranslationContext, locale_str, Translator from ..permissions import Permissions -from ..enums import AppCommandOptionType, AppCommandType, AppCommandPermissionType, ChannelType, Locale, try_enum +from ..enums import ( + AppCommandOptionType, + AppCommandType, + AppCommandPermissionType, + ChannelType, + Locale, + try_enum, +) from ..mixins import Hashable from ..utils import _get_as_snowflake, parse_time, snowflake_time, MISSING from ..object import Object @@ -160,6 +168,14 @@ class AppCommand(Hashable): The default member permissions that can run this command. dm_permission: :class:`bool` A boolean that indicates whether this command can be run in direct messages. + allowed_contexts: Optional[:class:`~discord.app_commands.AppCommandContext`] + The contexts that this command is allowed to be used in. Overrides the ``dm_permission`` attribute. + + .. versionadded:: 2.4 + allowed_installs: Optional[:class:`~discord.app_commands.AppInstallationType`] + The installation contexts that this command is allowed to be installed in. + + .. versionadded:: 2.4 guild_id: Optional[:class:`int`] The ID of the guild this command is registered in. A value of ``None`` denotes that it is a global command. @@ -179,6 +195,8 @@ class AppCommand(Hashable): 'options', 'default_member_permissions', 'dm_permission', + 'allowed_contexts', + 'allowed_installs', 'nsfw', '_state', ) @@ -210,6 +228,19 @@ class AppCommand(Hashable): dm_permission = True self.dm_permission: bool = dm_permission + + allowed_contexts = data.get('contexts') + if allowed_contexts is None: + self.allowed_contexts: Optional[AppCommandContext] = None + else: + self.allowed_contexts = AppCommandContext._from_value(allowed_contexts) + + allowed_installs = data.get('integration_types') + if allowed_installs is None: + self.allowed_installs: Optional[AppInstallationType] = None + else: + self.allowed_installs = AppInstallationType._from_value(allowed_installs) + self.nsfw: bool = data.get('nsfw', False) self.name_localizations: Dict[Locale, str] = _to_locale_dict(data.get('name_localizations') or {}) self.description_localizations: Dict[Locale, str] = _to_locale_dict(data.get('description_localizations') or {}) @@ -223,6 +254,8 @@ class AppCommand(Hashable): 'description': self.description, 'name_localizations': {str(k): v for k, v in self.name_localizations.items()}, 'description_localizations': {str(k): v for k, v in self.description_localizations.items()}, + 'contexts': self.allowed_contexts.to_array() if self.allowed_contexts is not None else None, + 'integration_types': self.allowed_installs.to_array() if self.allowed_installs is not None else None, 'options': [opt.to_dict() for opt in self.options], } # type: ignore # Type checker does not understand this literal. diff --git a/discord/app_commands/namespace.py b/discord/app_commands/namespace.py index 7fad617c6..3fa81712c 100644 --- a/discord/app_commands/namespace.py +++ b/discord/app_commands/namespace.py @@ -179,7 +179,7 @@ class Namespace: state = interaction._state members = resolved.get('members', {}) guild_id = interaction.guild_id - guild = state._get_or_create_unavailable_guild(guild_id) if guild_id is not None else None + guild = interaction.guild type = AppCommandOptionType.user.value for (user_id, user_data) in resolved.get('users', {}).items(): try: @@ -220,7 +220,6 @@ class Namespace: } ) - guild = state._get_guild(guild_id) for (message_id, message_data) in resolved.get('messages', {}).items(): channel_id = int(message_data['channel_id']) if guild is None: @@ -232,6 +231,7 @@ class Namespace: # Type checker doesn't understand this due to failure to narrow message = Message(state=state, channel=channel, data=message_data) # type: ignore + message.guild = guild key = ResolveKey(id=message_id, type=-1) completed[key] = message diff --git a/discord/app_commands/tree.py b/discord/app_commands/tree.py index c75682e0e..abd892480 100644 --- a/discord/app_commands/tree.py +++ b/discord/app_commands/tree.py @@ -58,6 +58,7 @@ from .errors import ( CommandSyncFailure, MissingApplicationID, ) +from .installs import AppCommandContext, AppInstallationType from .translator import Translator, locale_str from ..errors import ClientException, HTTPException from ..enums import AppCommandType, InteractionType @@ -121,9 +122,26 @@ class CommandTree(Generic[ClientT]): to find the guild-specific ``/ping`` command it will fall back to the global ``/ping`` command. This has the potential to raise more :exc:`~discord.app_commands.CommandSignatureMismatch` errors than usual. Defaults to ``True``. + allowed_contexts: :class:`~discord.app_commands.AppCommandContext` + The default allowed contexts that applies to all commands in this tree. + Note that you can override this on a per command basis. + + .. versionadded:: 2.4 + allowed_installs: :class:`~discord.app_commands.AppInstallationType` + The default allowed install locations that apply to all commands in this tree. + Note that you can override this on a per command basis. + + .. versionadded:: 2.4 """ - def __init__(self, client: ClientT, *, fallback_to_global: bool = True): + def __init__( + self, + client: ClientT, + *, + fallback_to_global: bool = True, + allowed_contexts: AppCommandContext = MISSING, + allowed_installs: AppInstallationType = MISSING, + ): self.client: ClientT = client self._http = client.http self._state = client._connection @@ -133,6 +151,8 @@ class CommandTree(Generic[ClientT]): self._state._command_tree = self self.fallback_to_global: bool = fallback_to_global + self.allowed_contexts = AppCommandContext() if allowed_contexts is MISSING else allowed_contexts + self.allowed_installs = AppInstallationType() if allowed_installs is MISSING else allowed_installs self._guild_commands: Dict[int, Dict[str, Union[Command, Group]]] = {} self._global_commands: Dict[str, Union[Command, Group]] = {} # (name, guild_id, command_type): Command @@ -722,7 +742,7 @@ class CommandTree(Generic[ClientT]): else: guild_id = None if guild is None else guild.id value = type.value - for ((_, g, t), command) in self._context_menus.items(): + for (_, g, t), command in self._context_menus.items(): if g == guild_id and t == value: yield command @@ -1058,9 +1078,9 @@ class CommandTree(Generic[ClientT]): translator = self.translator if translator: - payload = [await command.get_translated_payload(translator) for command in commands] + payload = [await command.get_translated_payload(self, translator) for command in commands] else: - payload = [command.to_dict() for command in commands] + payload = [command.to_dict(self) for command in commands] try: if guild is None: diff --git a/discord/channel.py b/discord/channel.py index 52bb47069..f60e22c0d 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -2916,22 +2916,21 @@ class DMChannel(discord.abc.Messageable, discord.abc.PrivateChannel, Hashable): The user you are participating with in the direct message channel. If this channel is received through the gateway, the recipient information may not be always available. + recipients: List[:class:`User`] + The users you are participating with in the DM channel. + + .. versionadded:: 2.4 me: :class:`ClientUser` The user presenting yourself. id: :class:`int` The direct message channel ID. """ - __slots__ = ('id', 'recipient', 'me', '_state') + __slots__ = ('id', 'recipients', 'me', '_state') def __init__(self, *, me: ClientUser, state: ConnectionState, data: DMChannelPayload): self._state: ConnectionState = state - self.recipient: Optional[User] = None - - recipients = data.get('recipients') - if recipients is not None: - self.recipient = state.store_user(recipients[0]) - + self.recipients: List[User] = [state.store_user(u) for u in data.get('recipients', [])] self.me: ClientUser = me self.id: int = int(data['id']) @@ -2951,11 +2950,17 @@ class DMChannel(discord.abc.Messageable, discord.abc.PrivateChannel, Hashable): self = cls.__new__(cls) self._state = state self.id = channel_id - self.recipient = None + self.recipients = [] # state.user won't be None here self.me = state.user # type: ignore return self + @property + def recipient(self) -> Optional[User]: + if self.recipients: + return self.recipients[0] + return None + @property def type(self) -> Literal[ChannelType.private]: """:class:`ChannelType`: The channel's Discord type.""" diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index b691c5af2..208948335 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -166,6 +166,8 @@ class BotBase(GroupMixin[None]): help_command: Optional[HelpCommand] = _default, tree_cls: Type[app_commands.CommandTree[Any]] = app_commands.CommandTree, description: Optional[str] = None, + allowed_contexts: app_commands.AppCommandContext = MISSING, + allowed_installs: app_commands.AppInstallationType = MISSING, intents: discord.Intents, **options: Any, ) -> None: @@ -174,6 +176,11 @@ class BotBase(GroupMixin[None]): self.extra_events: Dict[str, List[CoroFunc]] = {} # Self doesn't have the ClientT bound, but since this is a mixin it technically does self.__tree: app_commands.CommandTree[Self] = tree_cls(self) # type: ignore + if allowed_contexts is not MISSING: + self.__tree.allowed_contexts = allowed_contexts + if allowed_installs is not MISSING: + self.__tree.allowed_installs = allowed_installs + self.__cogs: Dict[str, Cog] = {} self.__extensions: Dict[str, types.ModuleType] = {} self._checks: List[UserCheck] = [] @@ -521,7 +528,6 @@ class BotBase(GroupMixin[None]): elif self.owner_ids: return user.id in self.owner_ids else: - app: discord.AppInfo = await self.application_info() # type: ignore if app.team: self.owner_ids = ids = { @@ -1489,6 +1495,20 @@ class Bot(BotBase, discord.Client): The type of application command tree to use. Defaults to :class:`~discord.app_commands.CommandTree`. .. versionadded:: 2.0 + allowed_contexts: :class:`~discord.app_commands.AppCommandContext` + The default allowed contexts that applies to all application commands + in the application command tree. + + Note that you can override this on a per command basis. + + .. versionadded:: 2.4 + allowed_installs: :class:`~discord.app_commands.AppInstallationType` + The default allowed install locations that apply to all application commands + in the application command tree. + + Note that you can override this on a per command basis. + + .. versionadded:: 2.4 """ pass diff --git a/discord/ext/commands/cog.py b/discord/ext/commands/cog.py index 54842c259..659d69ebb 100644 --- a/discord/ext/commands/cog.py +++ b/discord/ext/commands/cog.py @@ -318,6 +318,8 @@ class Cog(metaclass=CogMeta): parent=None, guild_ids=getattr(cls, '__discord_app_commands_default_guilds__', None), guild_only=getattr(cls, '__discord_app_commands_guild_only__', False), + allowed_contexts=getattr(cls, '__discord_app_commands_contexts__', None), + allowed_installs=getattr(cls, '__discord_app_commands_installation_types__', None), default_permissions=getattr(cls, '__discord_app_commands_default_permissions__', None), extras=cls.__cog_group_extras__, ) diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index 5fc675acd..d4052cbbd 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -472,7 +472,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): .. versionadded:: 2.0 """ - if self.channel.type is ChannelType.private: + if self.interaction is None and self.channel.type is ChannelType.private: return Permissions._dm_permissions() if not self.interaction: # channel and author will always match relevant types here @@ -506,7 +506,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): .. versionadded:: 2.0 """ channel = self.channel - if channel.type == ChannelType.private: + if self.interaction is None and channel.type == ChannelType.private: return Permissions._dm_permissions() if not self.interaction: # channel and me will always match relevant types here diff --git a/discord/ext/commands/hybrid.py b/discord/ext/commands/hybrid.py index c9797e734..8c2f9a9e9 100644 --- a/discord/ext/commands/hybrid.py +++ b/discord/ext/commands/hybrid.py @@ -653,6 +653,8 @@ class HybridGroup(Group[CogT, P, T]): guild_only = getattr(self.callback, '__discord_app_commands_guild_only__', False) default_permissions = getattr(self.callback, '__discord_app_commands_default_permissions__', None) nsfw = getattr(self.callback, '__discord_app_commands_is_nsfw__', False) + contexts = getattr(self.callback, '__discord_app_commands_contexts__', MISSING) + installs = getattr(self.callback, '__discord_app_commands_installation_types__', MISSING) self.app_command = app_commands.Group( name=self._locale_name or self.name, description=self._locale_description or self.description or self.short_doc or '…', @@ -660,6 +662,8 @@ class HybridGroup(Group[CogT, P, T]): guild_only=guild_only, default_permissions=default_permissions, nsfw=nsfw, + allowed_installs=installs, + allowed_contexts=contexts, ) # This prevents the group from re-adding the command at __init__ diff --git a/discord/flags.py b/discord/flags.py index 6e5721fcf..249c2e8f6 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -58,8 +58,10 @@ __all__ = ( 'ChannelFlags', 'AutoModPresets', 'MemberFlags', + 'AppCommandContext', 'AttachmentFlags', 'RoleFlags', + 'AppInstallationType', 'SKUFlags', ) @@ -1660,8 +1662,24 @@ class ArrayFlags(BaseFlags): self.value = reduce(or_, map((1).__lshift__, value), 0) >> 1 return self - def to_array(self) -> List[int]: - return [i + 1 for i in range(self.value.bit_length()) if self.value & (1 << i)] + def to_array(self, *, offset: int = 0) -> List[int]: + return [i + offset for i in range(self.value.bit_length()) if self.value & (1 << i)] + + @classmethod + def all(cls: Type[Self]) -> Self: + """A factory method that creates an instance of ArrayFlags with everything enabled.""" + bits = max(cls.VALID_FLAGS.values()).bit_length() + value = (1 << bits) - 1 + self = cls.__new__(cls) + self.value = value + return self + + @classmethod + def none(cls: Type[Self]) -> Self: + """A factory method that creates an instance of ArrayFlags with everything disabled.""" + self = cls.__new__(cls) + self.value = self.DEFAULT_VALUE + return self @fill_with_flags() @@ -1728,6 +1746,9 @@ class AutoModPresets(ArrayFlags): rather than using this raw value. """ + def to_array(self) -> List[int]: + return super().to_array(offset=1) + @flag_value def profanity(self): """:class:`bool`: Whether to use the preset profanity filter.""" @@ -1743,21 +1764,144 @@ class AutoModPresets(ArrayFlags): """:class:`bool`: Whether to use the preset slurs filter.""" return 1 << 2 - @classmethod - def all(cls: Type[Self]) -> Self: - """A factory method that creates a :class:`AutoModPresets` with everything enabled.""" - bits = max(cls.VALID_FLAGS.values()).bit_length() - value = (1 << bits) - 1 - self = cls.__new__(cls) - self.value = value - return self - @classmethod - def none(cls: Type[Self]) -> Self: - """A factory method that creates a :class:`AutoModPresets` with everything disabled.""" - self = cls.__new__(cls) - self.value = self.DEFAULT_VALUE - return self +@fill_with_flags() +class AppCommandContext(ArrayFlags): + r"""Wraps up the Discord :class:`~discord.app_commands.Command` execution context. + + .. versionadded:: 2.4 + + .. container:: operations + + .. describe:: x == y + + Checks if two AppCommandContext flags are equal. + + .. describe:: x != y + + Checks if two AppCommandContext flags are not equal. + + .. describe:: x | y, x |= y + + Returns an AppCommandContext instance with all enabled flags from + both x and y. + + .. describe:: x & y, x &= y + + Returns an AppCommandContext instance with only flags enabled on + both x and y. + + .. describe:: x ^ y, x ^= y + + Returns an AppCommandContext instance with only flags enabled on + only one of x or y, not on both. + + .. describe:: ~x + + Returns an AppCommandContext instance with all flags inverted from x + + .. describe:: hash(x) + + Return the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + .. describe:: bool(b) + + Returns whether any flag is set to ``True``. + + Attributes + ----------- + value: :class:`int` + The raw value. You should query flags via the properties + rather than using this raw value. + """ + + DEFAULT_VALUE = 3 + + @flag_value + def guild(self): + """:class:`bool`: Whether the context allows usage in a guild.""" + return 1 << 0 + + @flag_value + def dm_channel(self): + """:class:`bool`: Whether the context allows usage in a DM channel.""" + return 1 << 1 + + @flag_value + def private_channel(self): + """:class:`bool`: Whether the context allows usage in a DM or a GDM channel.""" + return 1 << 2 + + +@fill_with_flags() +class AppInstallationType(ArrayFlags): + r"""Represents the installation location of an application command. + + .. versionadded:: 2.4 + + .. container:: operations + + .. describe:: x == y + + Checks if two AppInstallationType flags are equal. + + .. describe:: x != y + + Checks if two AppInstallationType flags are not equal. + + .. describe:: x | y, x |= y + + Returns an AppInstallationType instance with all enabled flags from + both x and y. + + .. describe:: x & y, x &= y + + Returns an AppInstallationType instance with only flags enabled on + both x and y. + + .. describe:: x ^ y, x ^= y + + Returns an AppInstallationType instance with only flags enabled on + only one of x or y, not on both. + + .. describe:: ~x + + Returns an AppInstallationType instance with all flags inverted from x + + .. describe:: hash(x) + + Return the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + Note that aliases are not shown. + + .. describe:: bool(b) + + Returns whether any flag is set to ``True``. + + Attributes + ----------- + value: :class:`int` + The raw value. You should query flags via the properties + rather than using this raw value. + """ + + @flag_value + def guild(self): + """:class:`bool`: Whether the integration is a guild install.""" + return 1 << 0 + + @flag_value + def user(self): + """:class:`bool`: Whether the integration is a user install.""" + return 1 << 1 @fill_with_flags() diff --git a/discord/guild.py b/discord/guild.py index 6d1c5bde3..2a23d193f 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -455,8 +455,11 @@ class Guild(Hashable): return role @classmethod - def _create_unavailable(cls, *, state: ConnectionState, guild_id: int) -> Guild: - return cls(state=state, data={'id': guild_id, 'unavailable': True}) # type: ignore + def _create_unavailable(cls, *, state: ConnectionState, guild_id: int, data: Optional[Dict[str, Any]]) -> Guild: + if data is None: + data = {'unavailable': True} + data.update(id=guild_id) + return cls(state=state, data=data) # type: ignore def _from_data(self, guild: GuildPayload) -> None: try: diff --git a/discord/interactions.py b/discord/interactions.py index 48e73855e..5638886b3 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -45,6 +45,7 @@ from .message import Message, Attachment from .permissions import Permissions from .http import handle_message_parameters from .webhook.async_ import async_context, Webhook, interaction_response_params, interaction_message_response_params +from .app_commands.installs import AppCommandContext from .app_commands.namespace import Namespace from .app_commands.translator import locale_str, TranslationContext, TranslationContextLocation from .channel import _threaded_channel_factory @@ -64,6 +65,7 @@ if TYPE_CHECKING: from .types.webhook import ( Webhook as WebhookPayload, ) + from .types.snowflake import Snowflake from .guild import Guild from .state import ConnectionState from .file import File @@ -139,6 +141,10 @@ class Interaction(Generic[ClientT]): command_failed: :class:`bool` Whether the command associated with this interaction failed to execute. This includes checks and execution. + context: :class:`.AppCommandContext` + The context of the interaction. + + .. versionadded:: 2.4 """ __slots__: Tuple[str, ...] = ( @@ -157,6 +163,8 @@ class Interaction(Generic[ClientT]): 'command_failed', 'entitlement_sku_ids', 'entitlements', + "context", + '_integration_owners', '_permissions', '_app_permissions', '_state', @@ -194,6 +202,14 @@ class Interaction(Generic[ClientT]): self.application_id: int = int(data['application_id']) self.entitlement_sku_ids: List[int] = [int(x) for x in data.get('entitlement_skus', []) or []] self.entitlements: List[Entitlement] = [Entitlement(self._state, x) for x in data.get('entitlements', [])] + # This is not entirely useful currently, unsure how to expose it in a way that it is. + self._integration_owners: Dict[int, Snowflake] = { + int(k): int(v) for k, v in data.get('authorizing_integration_owners', {}).items() + } + try: + self.context = AppCommandContext._from_value([data['context']]) + except KeyError: + self.context = AppCommandContext() self.locale: Locale = try_enum(Locale, data.get('locale', 'en-US')) self.guild_locale: Optional[Locale] @@ -204,7 +220,10 @@ class Interaction(Generic[ClientT]): guild = None if self.guild_id: - guild = self._state._get_or_create_unavailable_guild(self.guild_id) + # The data type is a TypedDict but it doesn't narrow to Dict[str, Any] properly + guild = self._state._get_or_create_unavailable_guild(self.guild_id, data=data.get('guild')) # type: ignore + if guild.me is None and self._client.user is not None: + guild._add_member(Member._from_client_user(user=self._client.user, guild=guild, state=self._state)) raw_channel = data.get('channel', {}) channel_id = utils._get_as_snowflake(raw_channel, 'id') @@ -371,6 +390,22 @@ class Interaction(Generic[ClientT]): """:class:`bool`: Returns ``True`` if the interaction is expired.""" return utils.utcnow() >= self.expires_at + def is_guild_integration(self) -> bool: + """:class:`bool`: Returns ``True`` if the interaction is a guild integration. + + .. versionadded:: 2.4 + """ + if self.guild_id: + return self.guild_id == self._integration_owners.get(0) + return False + + def is_user_integration(self) -> bool: + """:class:`bool`: Returns ``True`` if the interaction is a user integration. + + .. versionadded:: 2.4 + """ + return self.user.id == self._integration_owners.get(1) + async def original_response(self) -> InteractionMessage: """|coro| diff --git a/discord/member.py b/discord/member.py index 4cfa54a35..74ba86932 100644 --- a/discord/member.py +++ b/discord/member.py @@ -35,7 +35,7 @@ import discord.abc from . import utils from .asset import Asset from .utils import MISSING -from .user import BaseUser, User, _UserTag +from .user import BaseUser, ClientUser, User, _UserTag from .activity import create_activity, ActivityTypes from .permissions import Permissions from .enums import Status, try_enum @@ -392,6 +392,15 @@ class Member(discord.abc.Messageable, _UserTag): data['user'] = author._to_minimal_user_json() # type: ignore return cls(data=data, guild=message.guild, state=message._state) # type: ignore + @classmethod + def _from_client_user(cls, *, user: ClientUser, guild: Guild, state: ConnectionState) -> Self: + data = { + 'roles': [], + 'user': user._to_minimal_user_json(), + 'flags': 0, + } + return cls(data=data, guild=guild, state=state) # type: ignore + def _update_from_message(self, data: MemberPayload) -> None: self.joined_at = utils.parse_time(data.get('joined_at')) self.premium_since = utils.parse_time(data.get('premium_since')) diff --git a/discord/state.py b/discord/state.py index fbde85cf0..a966cb667 100644 --- a/discord/state.py +++ b/discord/state.py @@ -429,8 +429,8 @@ class ConnectionState(Generic[ClientT]): # the keys of self._guilds are ints return self._guilds.get(guild_id) # type: ignore - def _get_or_create_unavailable_guild(self, guild_id: int) -> Guild: - return self._guilds.get(guild_id) or Guild._create_unavailable(state=self, guild_id=guild_id) + def _get_or_create_unavailable_guild(self, guild_id: int, *, data: Optional[Dict[str, Any]] = None) -> Guild: + return self._guilds.get(guild_id) or Guild._create_unavailable(state=self, guild_id=guild_id, data=data) def _add_guild(self, guild: Guild) -> None: self._guilds[guild.id] = guild @@ -1592,7 +1592,8 @@ class ConnectionState(Generic[ClientT]): if channel is not None: if isinstance(channel, DMChannel): - channel.recipient = raw.user + if raw.user is not None and raw.user not in channel.recipients: + channel.recipients.append(raw.user) elif guild is not None: raw.user = guild.get_member(raw.user_id) diff --git a/discord/types/command.py b/discord/types/command.py index f4eb41ef8..7876ee6dd 100644 --- a/discord/types/command.py +++ b/discord/types/command.py @@ -29,9 +29,11 @@ from typing_extensions import NotRequired, Required from .channel import ChannelType from .snowflake import Snowflake +from .interactions import InteractionContextType ApplicationCommandType = Literal[1, 2, 3] ApplicationCommandOptionType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] +ApplicationIntegrationType = Literal[0, 1] class _BaseApplicationCommandOption(TypedDict): @@ -141,6 +143,8 @@ class _BaseApplicationCommand(TypedDict): id: Snowflake application_id: Snowflake name: str + contexts: List[InteractionContextType] + integration_types: List[ApplicationIntegrationType] dm_permission: NotRequired[Optional[bool]] default_member_permissions: NotRequired[Optional[str]] nsfw: NotRequired[bool] diff --git a/discord/types/interactions.py b/discord/types/interactions.py index 52bb9c997..d9446ee0e 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -35,12 +35,15 @@ from .message import Attachment from .role import Role from .snowflake import Snowflake from .user import User +from .guild import GuildFeature if TYPE_CHECKING: from .message import Message InteractionType = Literal[1, 2, 3, 4, 5] +InteractionContextType = Literal[0, 1, 2] +InteractionInstallationType = Literal[0, 1] class _BasePartialChannel(TypedDict): @@ -68,6 +71,12 @@ class ResolvedData(TypedDict, total=False): attachments: Dict[str, Attachment] +class PartialInteractionGuild(TypedDict): + id: Snowflake + locale: str + features: List[GuildFeature] + + class _BaseApplicationCommandInteractionDataOption(TypedDict): name: str @@ -204,6 +213,7 @@ class _BaseInteraction(TypedDict): token: str version: Literal[1] guild_id: NotRequired[Snowflake] + guild: NotRequired[PartialInteractionGuild] channel_id: NotRequired[Snowflake] channel: Union[GuildChannel, InteractionDMChannel, GroupDMChannel] app_permissions: NotRequired[str] @@ -211,6 +221,8 @@ class _BaseInteraction(TypedDict): guild_locale: NotRequired[str] entitlement_sku_ids: NotRequired[List[Snowflake]] entitlements: NotRequired[List[Entitlement]] + authorizing_integration_owners: Dict[Literal['0', '1'], Snowflake] + context: NotRequired[InteractionContextType] class PingInteraction(_BaseInteraction): diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index 95c1922d1..6aa234257 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -129,6 +129,22 @@ AppCommandPermissions .. autoclass:: discord.app_commands.AppCommandPermissions() :members: +AppCommandContext +~~~~~~~~~~~~~~~~~ + +.. attributetable:: discord.app_commands.AppCommandContext + +.. autoclass:: discord.app_commands.AppCommandContext + :members: + +AppInstallationType +~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: discord.app_commands.AppInstallationType + +.. autoclass:: discord.app_commands.AppInstallationType + :members: + GuildAppCommandPermissions ~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -642,6 +658,24 @@ Decorators .. autofunction:: discord.app_commands.guild_only :decorator: +.. autofunction:: discord.app_commands.dm_only + :decorator: + +.. autofunction:: discord.app_commands.private_channel_only + :decorator: + +.. autofunction:: discord.app_commands.allowed_contexts + :decorator: + +.. autofunction:: discord.app_commands.user_install + :decorator: + +.. autofunction:: discord.app_commands.guild_install + :decorator: + +.. autofunction:: discord.app_commands.allowed_installs + :decorator: + .. autofunction:: discord.app_commands.default_permissions :decorator: