Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

First pass at supporting user apps #9760

Merged
merged 16 commits into from May 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions discord/app_commands/__init__.py
Expand Up @@ -16,5 +16,6 @@
from .namespace import *
from .transformers import *
from .translator import *
from .installs import *
from . import checks as checks
from .checks import Cooldown as Cooldown
356 changes: 345 additions & 11 deletions discord/app_commands/commands.py

Large diffs are not rendered by default.

207 changes: 207 additions & 0 deletions 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
35 changes: 34 additions & 1 deletion discord/app_commands/models.py
Expand Up @@ -26,9 +26,17 @@
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
Expand Down Expand Up @@ -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.
Expand All @@ -179,6 +195,8 @@ class AppCommand(Hashable):
'options',
'default_member_permissions',
'dm_permission',
'allowed_contexts',
'allowed_installs',
'nsfw',
'_state',
)
Expand Down Expand Up @@ -210,6 +228,19 @@ def _from_data(self, data: ApplicationCommandPayload) -> None:
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 {})
Expand All @@ -223,6 +254,8 @@ def to_dict(self) -> ApplicationCommandPayload:
'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.

Expand Down
4 changes: 2 additions & 2 deletions discord/app_commands/namespace.py
Expand Up @@ -179,7 +179,7 @@ def _get_resolved_items(cls, interaction: Interaction, resolved: ResolvedData) -
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:
Expand Down Expand Up @@ -220,7 +220,6 @@ def _get_resolved_items(cls, interaction: Interaction, resolved: ResolvedData) -
}
)

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:
Expand All @@ -232,6 +231,7 @@ def _get_resolved_items(cls, interaction: Interaction, resolved: ResolvedData) -

# 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

Expand Down
28 changes: 24 additions & 4 deletions discord/app_commands/tree.py
Expand Up @@ -58,6 +58,7 @@
CommandSyncFailure,
MissingApplicationID,
)
from .installs import AppCommandContext, AppInstallationType
from .translator import Translator, locale_str
from ..errors import ClientException, HTTPException
from ..enums import AppCommandType, InteractionType
Expand Down Expand Up @@ -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
Expand All @@ -133,6 +151,8 @@ def __init__(self, client: ClientT, *, fallback_to_global: bool = True):

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
Expand Down Expand Up @@ -722,7 +742,7 @@ def walk_commands(
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

Expand Down Expand Up @@ -1058,9 +1078,9 @@ async def sync(self, *, guild: Optional[Snowflake] = None) -> List[AppCommand]:

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:
Expand Down