Browse Source

Add support for autocomplete

pull/7492/head
Rapptz 3 years ago
parent
commit
ae1aaac5a7
  1. 2
      discord/app_commands/__init__.py
  2. 215
      discord/app_commands/commands.py
  3. 2
      discord/app_commands/models.py
  4. 2
      discord/app_commands/namespace.py
  5. 21
      discord/app_commands/transformers.py
  6. 15
      discord/app_commands/tree.py
  7. 2
      discord/enums.py
  8. 42
      discord/interactions.py
  9. 2
      discord/state.py
  10. 8
      docs/api.rst

2
discord/app_commands/__init__.py

@ -14,5 +14,5 @@ from .enums import *
from .errors import * from .errors import *
from .models import * from .models import *
from .tree import * from .tree import *
from .namespace import Namespace from .namespace import *
from .transformers import * from .transformers import *

215
discord/app_commands/commands.py

@ -37,39 +37,27 @@ from typing import (
Set, Set,
TYPE_CHECKING, TYPE_CHECKING,
Tuple, Tuple,
Type,
TypeVar, TypeVar,
Union, Union,
) )
from textwrap import TextWrapper from textwrap import TextWrapper
import sys
import re import re
from .enums import AppCommandOptionType, AppCommandType from .enums import AppCommandOptionType, AppCommandType
from ..interactions import Interaction from ..interactions import Interaction
from ..enums import ChannelType, try_enum from .models import Choice
from .models import AppCommandChannel, AppCommandThread, Choice
from .transformers import annotation_to_parameter, CommandParameter, NoneType from .transformers import annotation_to_parameter, CommandParameter, NoneType
from .errors import AppCommandError, CommandInvokeError, CommandSignatureMismatch, CommandAlreadyRegistered from .errors import AppCommandError, CommandInvokeError, CommandSignatureMismatch, CommandAlreadyRegistered
from ..utils import resolve_annotation, MISSING, is_inside_class from ..utils import resolve_annotation, MISSING, is_inside_class
from ..user import User
from ..member import Member
from ..role import Role
from ..message import Message
from ..mixins import Hashable
from ..permissions import Permissions
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import ParamSpec, Concatenate from typing_extensions import ParamSpec, Concatenate
from ..types.interactions import ( from ..user import User
ResolvedData, from ..member import Member
PartialThread, from ..message import Message
PartialChannel,
ApplicationCommandInteractionDataOption,
)
from ..state import ConnectionState
from .namespace import Namespace from .namespace import Namespace
from .models import ChoiceT
__all__ = ( __all__ = (
'Command', 'Command',
@ -93,25 +81,33 @@ Error = Union[
Callable[[Interaction, AppCommandError], Coro[Any]], Callable[[Interaction, AppCommandError], Coro[Any]],
] ]
ContextMenuCallback = Union[
# If groups end up support context menus these would be uncommented
# Callable[[GroupT, Interaction, Member], Coro[Any]],
# Callable[[GroupT, Interaction, User], Coro[Any]],
# Callable[[GroupT, Interaction, Message], Coro[Any]],
# Callable[[GroupT, Interaction, Union[Member, User]], Coro[Any]],
Callable[[Interaction, Member], Coro[Any]],
Callable[[Interaction, User], Coro[Any]],
Callable[[Interaction, Message], Coro[Any]],
Callable[[Interaction, Union[Member, User]], Coro[Any]],
]
if TYPE_CHECKING: if TYPE_CHECKING:
CommandCallback = Union[ CommandCallback = Union[
Callable[Concatenate[GroupT, Interaction, P], Coro[T]], Callable[Concatenate[GroupT, Interaction, P], Coro[T]],
Callable[Concatenate[Interaction, P], Coro[T]], Callable[Concatenate[Interaction, P], Coro[T]],
] ]
ContextMenuCallback = Union[
# If groups end up support context menus these would be uncommented
# Callable[[GroupT, Interaction, Member], Coro[Any]],
# Callable[[GroupT, Interaction, User], Coro[Any]],
# Callable[[GroupT, Interaction, Message], Coro[Any]],
# Callable[[GroupT, Interaction, Union[Member, User]], Coro[Any]],
Callable[[Interaction, Member], Coro[Any]],
Callable[[Interaction, User], Coro[Any]],
Callable[[Interaction, Message], Coro[Any]],
Callable[[Interaction, Union[Member, User]], Coro[Any]],
]
AutocompleteCallback = Union[
Callable[[GroupT, Interaction, ChoiceT, Namespace], Coro[List[Choice[ChoiceT]]]],
Callable[[Interaction, ChoiceT, Namespace], Coro[List[Choice[ChoiceT]]]],
]
else: else:
CommandCallback = Callable[..., Coro[T]] CommandCallback = Callable[..., Coro[T]]
ContextMenuCallback = Callable[..., Coro[T]]
AutocompleteCallback = Callable[..., Coro[T]]
VALID_SLASH_COMMAND_NAME = re.compile(r'^[\w-]{1,32}$') VALID_SLASH_COMMAND_NAME = re.compile(r'^[\w-]{1,32}$')
@ -197,6 +193,25 @@ def _populate_choices(params: Dict[str, CommandParameter], all_choices: Dict[str
raise TypeError(f'unknown parameter given: {first}') raise TypeError(f'unknown parameter given: {first}')
def _populate_autocomplete(params: Dict[str, CommandParameter], autocomplete: Dict[str, Any]) -> None:
for name, param in params.items():
callback = autocomplete.pop(name, MISSING)
if callback is MISSING:
continue
if not inspect.iscoroutinefunction(callback):
raise TypeError('autocomplete callback must be a coroutine function')
if param.type not in (AppCommandOptionType.string, AppCommandOptionType.number, AppCommandOptionType.integer):
raise TypeError('autocomplete is only supported for integer, string, or number option types')
param.autocomplete = callback
if autocomplete:
first = next(iter(autocomplete))
raise TypeError(f'unknown parameter given: {first}')
def _extract_parameters_from_callback(func: Callable[..., Any], globalns: Dict[str, Any]) -> Dict[str, CommandParameter]: def _extract_parameters_from_callback(func: Callable[..., Any], globalns: Dict[str, Any]) -> Dict[str, CommandParameter]:
params = inspect.signature(func).parameters params = inspect.signature(func).parameters
cache = {} cache = {}
@ -236,6 +251,13 @@ def _extract_parameters_from_callback(func: Callable[..., Any], globalns: Dict[s
else: else:
_populate_choices(result, choices) _populate_choices(result, choices)
try:
autocomplete = func.__discord_app_commands_param_autocomplete__
except AttributeError:
pass
else:
_populate_autocomplete(result, autocomplete)
return result return result
@ -381,6 +403,27 @@ class Command(Generic[GroupT, P, T]):
except Exception as e: except Exception as e:
raise CommandInvokeError(self, e) from e raise CommandInvokeError(self, e) from e
async def _invoke_autocomplete(self, interaction: Interaction, name: str, namespace: Namespace):
value = namespace.__dict__[name]
try:
param = self._params[name]
except KeyError:
raise CommandSignatureMismatch(self) from None
if param.autocomplete is None:
raise CommandSignatureMismatch(self)
if self.binding is not None:
choices = await param.autocomplete(self.binding, interaction, value, namespace)
else:
choices = await param.autocomplete(interaction, value, namespace)
if interaction.response.is_done():
return
await interaction.response.autocomplete(choices)
def _get_internal_command(self, name: str) -> Optional[Union[Command, Group]]: def _get_internal_command(self, name: str) -> Optional[Union[Command, Group]]:
return None return None
@ -418,6 +461,69 @@ class Command(Generic[GroupT, P, T]):
self.on_error = coro self.on_error = coro
return coro return coro
def autocomplete(
self, name: str
) -> Callable[[AutocompleteCallback[GroupT, ChoiceT]], AutocompleteCallback[GroupT, ChoiceT]]:
"""A decorator that registers a coroutine as an autocomplete prompt for a parameter.
The coroutine callback must have 3 parameters, the :class:`~discord.Interaction`,
the current value by the user (usually either a :class:`str`, :class:`int`, or :class:`float`,
depending on the type of the parameter being marked as autocomplete), and then the
:class:`Namespace` that represents possible values are partially filled in.
The coroutine decorator **must** return a list of :class:`~discord.app_commands.Choice` objects.
Only up to 25 objects are supported.
Example:
.. code-block:: python3
@app_commands.command()
async def fruits(interaction: discord.Interaction, fruits: str):
await interaction.response.send_message(f'Your favourite fruit seems to be {fruits}')
@fruits.autocomplete('fruits')
async def fruits_autocomplete(
interaction: discord.Interaction,
current: str,
namespace: app_commands.Namespace
) -> List[app_commands.Choice[str]]:
fruits = ['Banana', 'Pineapple', 'Apple', 'Watermelon', 'Melon', 'Cherry']
return [
app_commands.Choice(name=fruit, value=fruit)
for fruit in fruits if current.lower() in fruit.lower()
]
Parameters
-----------
name: :clas:`str`
The parameter name to register as autocomplete.
Raises
-------
TypeError
The coroutine passed is not actually a coroutine or
the parameter is not found or of an invalid type.
"""
def decorator(coro: AutocompleteCallback[GroupT, ChoiceT]) -> AutocompleteCallback[GroupT, ChoiceT]:
if not inspect.iscoroutinefunction(coro):
raise TypeError('The error handler must be a coroutine.')
try:
param = self._params[name]
except KeyError:
raise TypeError(f'unknown parameter: {name!r}') from None
if param.type not in (AppCommandOptionType.string, AppCommandOptionType.number, AppCommandOptionType.integer):
raise TypeError('autocomplete is only supported for integer, string, or number option types')
param.autocomplete = coro
return coro
return decorator
class ContextMenu: class ContextMenu:
"""A class that implements a context menu application command. """A class that implements a context menu application command.
@ -882,7 +988,7 @@ def choices(**parameters: List[Choice]) -> Callable[[T], T]:
Raises Raises
-------- --------
TypeError TypeError
The parameter name is not found. The parameter name is not found or the parameter type was incorrect.
""" """
def decorator(inner: T) -> T: def decorator(inner: T) -> T:
@ -897,3 +1003,54 @@ def choices(**parameters: List[Choice]) -> Callable[[T], T]:
return inner return inner
return decorator return decorator
def autocomplete(**parameters: AutocompleteCallback[GroupT, ChoiceT]) -> Callable[[T], T]:
r"""Associates the given parameters with the given autocomplete callback.
Autocomplete is only supported on types that have :class:`str`, :class:`int`, or :class:`float`
values.
Example:
.. code-block:: python3
@app_commands.command()
@app_commands.autocomplete(fruits=fruits_autocomplete)
async def fruits(interaction: discord.Interaction, fruits: str):
await interaction.response.send_message(f'Your favourite fruit seems to be {fruits}')
async def fruits_autocomplete(
interaction: discord.Interaction,
current: str,
namespace: app_commands.Namespace
) -> List[app_commands.Choice[str]]:
fruits = ['Banana', 'Pineapple', 'Apple', 'Watermelon', 'Melon', 'Cherry']
return [
app_commands.Choice(name=fruit, value=fruit)
for fruit in fruits if current.lower() in fruit.lower()
]
Parameters
-----------
\*\*parameters
The parameters to mark as autocomplete.
Raises
--------
TypeError
The parameter name is not found or the parameter type was incorrect.
"""
def decorator(inner: T) -> T:
if isinstance(inner, Command):
_populate_autocomplete(inner._params, parameters)
else:
try:
inner.__discord_app_commands_param_autocomplete__.update(parameters) # type: ignore - Runtime attribute access
except AttributeError:
inner.__discord_app_commands_param_autocomplete__ = parameters # type: ignore - Runtime attribute assignment
return inner
return decorator

2
discord/app_commands/models.py

@ -31,7 +31,7 @@ from ..enums import ChannelType, try_enum
from ..mixins import Hashable from ..mixins import Hashable
from ..utils import _get_as_snowflake, parse_time, snowflake_time from ..utils import _get_as_snowflake, parse_time, snowflake_time
from .enums import AppCommandOptionType, AppCommandType from .enums import AppCommandOptionType, AppCommandType
from typing import Generic, List, NamedTuple, TYPE_CHECKING, Optional, TypeVar, Union from typing import Generic, List, TYPE_CHECKING, Optional, TypeVar, Union
__all__ = ( __all__ = (
'AppCommand', 'AppCommand',

2
discord/app_commands/namespace.py

@ -37,6 +37,8 @@ from .enums import AppCommandOptionType
if TYPE_CHECKING: if TYPE_CHECKING:
from ..types.interactions import ResolvedData, ApplicationCommandInteractionDataOption from ..types.interactions import ResolvedData, ApplicationCommandInteractionDataOption
__all__ = ('Namespace',)
class ResolveKey(NamedTuple): class ResolveKey(NamedTuple):
id: str id: str

21
discord/app_commands/transformers.py

@ -27,7 +27,22 @@ import inspect
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Literal, Optional, Set, Tuple, Type, TypeVar, Union from typing import (
TYPE_CHECKING,
Any,
Callable,
ClassVar,
Coroutine,
Dict,
List,
Literal,
Optional,
Set,
Tuple,
Type,
TypeVar,
Union,
)
from .enums import AppCommandOptionType from .enums import AppCommandOptionType
from .errors import TransformerError from .errors import TransformerError
@ -75,8 +90,6 @@ class CommandParameter:
The minimum supported value for this parameter. The minimum supported value for this parameter.
max_value: Optional[Union[:class:`int`, :class:`float`]] max_value: Optional[Union[:class:`int`, :class:`float`]]
The maximum supported value for this parameter. The maximum supported value for this parameter.
autocomplete: :class:`bool`
Whether this parameter enables autocomplete.
""" """
name: str = MISSING name: str = MISSING
@ -88,7 +101,7 @@ class CommandParameter:
channel_types: List[ChannelType] = MISSING channel_types: List[ChannelType] = MISSING
min_value: Optional[Union[int, float]] = None min_value: Optional[Union[int, float]] = None
max_value: Optional[Union[int, float]] = None max_value: Optional[Union[int, float]] = None
autocomplete: bool = MISSING autocomplete: Optional[Callable[..., Coroutine[Any, Any, Any]]] = None
_annotation: Any = MISSING _annotation: Any = MISSING
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:

15
discord/app_commands/tree.py

@ -26,7 +26,7 @@ from __future__ import annotations
import inspect import inspect
import sys import sys
import traceback import traceback
from typing import Callable, Dict, List, Literal, Optional, TYPE_CHECKING, Tuple, Type, Union, overload from typing import Callable, Dict, List, Literal, Optional, TYPE_CHECKING, Tuple, Union, overload
from .namespace import Namespace, ResolveKey from .namespace import Namespace, ResolveKey
@ -40,6 +40,7 @@ from .errors import (
CommandSignatureMismatch, CommandSignatureMismatch,
) )
from ..errors import ClientException from ..errors import ClientException
from ..enums import InteractionType
from ..utils import MISSING from ..utils import MISSING
if TYPE_CHECKING: if TYPE_CHECKING:
@ -580,7 +581,7 @@ class CommandTree:
raise CommandSignatureMismatch(ctx_menu) raise CommandSignatureMismatch(ctx_menu)
if value is None: if value is None:
raise RuntimeError('This should not happen if Discord sent well-formed data.') raise AppCommandError('This should not happen if Discord sent well-formed data.')
# I assume I don't have to type check here. # I assume I don't have to type check here.
try: try:
@ -608,6 +609,8 @@ class CommandTree:
CommandSignatureMismatch CommandSignatureMismatch
The interaction data referred to a parameter that was not found in the The interaction data referred to a parameter that was not found in the
application command definition. application command definition.
AppCommandError
An error occurred while calling the command.
""" """
data: ApplicationCommandInteractionData = interaction.data # type: ignore data: ApplicationCommandInteractionData = interaction.data # type: ignore
type = data.get('type', 1) type = data.get('type', 1)
@ -663,6 +666,14 @@ class CommandTree:
# and command refers to the class type we care about # and command refers to the class type we care about
namespace = Namespace(interaction, data.get('resolved', {}), options) namespace = Namespace(interaction, data.get('resolved', {}), options)
# Auto complete handles the namespace differently... so at this point this is where we decide where that is.
if interaction.type is InteractionType.autocomplete:
focused = next((opt['name'] for opt in options if opt.get('focused')), None)
if focused is None:
raise AppCommandError('This should not happen, but there is no focused element. This is a Discord bug.')
await command._invoke_autocomplete(interaction, focused, namespace)
return
try: try:
await command._invoke_with_namespace(interaction, namespace) await command._invoke_with_namespace(interaction, namespace)
except AppCommandError as e: except AppCommandError as e:

2
discord/enums.py

@ -516,6 +516,7 @@ class InteractionType(Enum):
ping = 1 ping = 1
application_command = 2 application_command = 2
component = 3 component = 3
autocomplete = 4
modal_submit = 5 modal_submit = 5
@ -527,6 +528,7 @@ class InteractionResponseType(Enum):
deferred_channel_message = 5 # (with source) deferred_channel_message = 5 # (with source)
deferred_message_update = 6 # for components deferred_message_update = 6 # for components
message_update = 7 # for components message_update = 7 # for components
autocomplete_result = 8
modal = 9 # for modals modal = 9 # for modals

42
discord/interactions.py

@ -60,6 +60,7 @@ if TYPE_CHECKING:
from aiohttp import ClientSession from aiohttp import ClientSession
from .embeds import Embed from .embeds import Embed
from .ui.view import View from .ui.view import View
from .app_commands.models import Choice, ChoiceT
from .ui.modal import Modal from .ui.modal import Modal
from .channel import VoiceChannel, StageChannel, TextChannel, CategoryChannel, StoreChannel, PartialMessageable from .channel import VoiceChannel, StageChannel, TextChannel, CategoryChannel, StoreChannel, PartialMessageable
from .threads import Thread from .threads import Thread
@ -686,6 +687,47 @@ class InteractionResponse:
self._parent._state.store_view(modal) self._parent._state.store_view(modal)
self._responded = True self._responded = True
async def autocomplete(self, choices: List[Choice[ChoiceT]]) -> None:
"""|coro|
Responds to this interaction by giving the user the choices they can use.
Parameters
-----------
choices: List[:class:`~discord.app_commands.Choice`]
The list of new choices as the user is typing.
Raises
-------
HTTPException
Sending the choices failed.
ValueError
This interaction cannot respond with autocomplete.
InteractionResponded
This interaction has already been responded to before.
"""
if self._responded:
raise InteractionResponded(self._parent)
payload: Dict[str, Any] = {
'choices': [option.to_dict() for option in choices],
}
parent = self._parent
if parent.type is not InteractionType.autocomplete:
raise ValueError('cannot respond to this interaction with autocomplete.')
adapter = async_context.get()
params = interaction_response_params(type=InteractionResponseType.autocomplete_result.value, data=payload)
await adapter.create_interaction_response(
parent.id,
parent.token,
session=parent._session,
params=params,
)
self._responded = True
class _InteractionMessageState: class _InteractionMessageState:
__slots__ = ('_parent', '_interaction') __slots__ = ('_parent', '_interaction')

2
discord/state.py

@ -692,7 +692,7 @@ class ConnectionState:
def parse_interaction_create(self, data: gw.InteractionCreateEvent) -> None: def parse_interaction_create(self, data: gw.InteractionCreateEvent) -> None:
interaction = Interaction(data=data, state=self) interaction = Interaction(data=data, state=self)
if data['type'] == 2 and self._command_tree: # application command if data['type'] in (2, 4) and self._command_tree: # application command and auto complete
self._command_tree._from_interaction(interaction) self._command_tree._from_interaction(interaction)
elif data['type'] == 3: # interaction component elif data['type'] == 3: # interaction component
# These keys are always there for this interaction type # These keys are always there for this interaction type

8
docs/api.rst

@ -1478,6 +1478,9 @@ of :class:`enum.Enum`.
.. attribute:: component .. attribute:: component
Represents a component based interaction, i.e. using the Discord Bot UI Kit. Represents a component based interaction, i.e. using the Discord Bot UI Kit.
.. attribute:: autocomplete
Represents an auto complete interaction.
.. attribute:: modal_submit .. attribute:: modal_submit
Represents submission of a modal interaction. Represents submission of a modal interaction.
@ -1514,6 +1517,11 @@ of :class:`enum.Enum`.
Responds to the interaction by editing the message. Responds to the interaction by editing the message.
See also :meth:`InteractionResponse.edit_message` See also :meth:`InteractionResponse.edit_message`
.. attribute:: autocomplete_result
Responds to the autocomplete interaction with suggested choices.
See also :meth:`InteractionResponse.autocomplete`
.. attribute:: modal .. attribute:: modal
Responds to the interaction with a modal. Responds to the interaction with a modal.

Loading…
Cancel
Save