From a19e43675fda7ab42e7db3265937048ee99414dd Mon Sep 17 00:00:00 2001 From: Gnome Date: Mon, 30 Aug 2021 16:14:44 +0100 Subject: [PATCH 01/32] Most slash command support completed, needs some debugging (and reindent) --- discord/client.py | 10 +- discord/embeds.py | 4 +- discord/ext/commands/bot.py | 134 +++++++++++++++++++++++- discord/ext/commands/context.py | 2 + discord/ext/commands/core.py | 77 +++++++++++++- discord/ext/commands/view.py | 6 +- discord/interactions.py | 6 +- discord/opus.py | 2 +- discord/template.py | 2 +- discord/threads.py | 2 +- discord/types/interactions.py | 3 +- discord/voice_client.py | 2 +- docs/conf.py | 8 +- docs/extensions/details.py | 1 - docs/extensions/nitpick_file_ignorer.py | 2 +- examples/converters.py | 2 +- examples/custom_context.py | 4 +- examples/secret.py | 8 +- examples/views/dropdown.py | 6 +- setup.py | 2 +- 20 files changed, 238 insertions(+), 45 deletions(-) diff --git a/discord/client.py b/discord/client.py index b6198d10..250e9da1 100644 --- a/discord/client.py +++ b/discord/client.py @@ -329,7 +329,7 @@ class Client: If this is not passed via ``__init__`` then this is retrieved through the gateway when an event contains the data. Usually after :func:`~discord.on_connect` is called. - + .. versionadded:: 2.0 """ return self._connection.application_id @@ -687,7 +687,7 @@ class Client: self._connection._activity = value.to_dict() # type: ignore else: raise TypeError('activity must derive from BaseActivity.') - + @property def status(self): """:class:`.Status`: @@ -758,7 +758,7 @@ class Client: This is useful if you have a channel_id but don't want to do an API call to send messages to it. - + .. versionadded:: 2.0 Parameters @@ -1604,7 +1604,7 @@ class Client: This method should be used for when a view is comprised of components that last longer than the lifecycle of the program. - + .. versionadded:: 2.0 Parameters @@ -1636,7 +1636,7 @@ class Client: @property def persistent_views(self) -> Sequence[View]: """Sequence[:class:`.View`]: A sequence of persistent views added to the client. - + .. versionadded:: 2.0 """ return self._connection.persistent_views diff --git a/discord/embeds.py b/discord/embeds.py index 7033a10e..d332b4e6 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -366,7 +366,7 @@ class Embed: self._footer['icon_url'] = str(icon_url) return self - + def remove_footer(self: E) -> E: """Clears embed's footer information. @@ -381,7 +381,7 @@ class Embed: pass return self - + @property def image(self) -> _EmbedMediaProxy: """Returns an ``EmbedProxy`` denoting the image contents. diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index b4da6100..92c00ea3 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -28,17 +28,23 @@ from __future__ import annotations import asyncio import collections import collections.abc + import inspect import importlib.util import sys import traceback import types -from typing import Any, Callable, Mapping, List, Dict, TYPE_CHECKING, Optional, TypeVar, Type, Union +from typing import Any, Callable, cast, Mapping, List, Dict, TYPE_CHECKING, Optional, TypeVar, Type, Union import discord +from discord.types.interactions import ( + ApplicationCommandInteractionData, + _ApplicationCommandInteractionDataOptionString +) from .core import GroupMixin -from .view import StringView +from .converter import Greedy +from .view import StringView, supported_quotes from .context import Context from . import errors from .help import HelpCommand, DefaultHelpCommand @@ -66,6 +72,13 @@ T = TypeVar('T') CFT = TypeVar('CFT', bound='CoroFunc') CXT = TypeVar('CXT', bound='Context') +class _FakeSlashMessage(discord.PartialMessage): + activity = application = edited_at = reference = webhook_id = None + attachments = components = reactions = stickers = [] + author: Union[discord.User, discord.Member] + tts = False + + def when_mentioned(bot: Union[Bot, AutoShardedBot], msg: Message) -> List[str]: """A callable that implements a command prefix equivalent to being mentioned. @@ -120,9 +133,17 @@ class _DefaultRepr: _default = _DefaultRepr() class BotBase(GroupMixin): - def __init__(self, command_prefix, help_command=_default, description=None, **options): + def __init__(self, + command_prefix, + help_command=_default, + description=None, + message_commands: bool = True, + slash_commands: bool = False, **options + ): super().__init__(**options) self.command_prefix = command_prefix + self.slash_commands = slash_commands + self.message_commands = message_commands self.extra_events: Dict[str, List[CoroFunc]] = {} self.__cogs: Dict[str, Cog] = {} self.__extensions: Dict[str, types.ModuleType] = {} @@ -142,11 +163,17 @@ class BotBase(GroupMixin): if self.owner_ids and not isinstance(self.owner_ids, collections.abc.Collection): raise TypeError(f'owner_ids must be a collection not {self.owner_ids.__class__!r}') + if not (message_commands or slash_commands): + raise TypeError("Both message_commands and slash_commands are disabled.") + elif slash_commands: + self.slash_command_guild = options['slash_command_guild'] + if help_command is _default: self.help_command = DefaultHelpCommand() else: self.help_command = help_command + # internal helpers def dispatch(self, event_name: str, *args: Any, **kwargs: Any) -> None: @@ -1031,7 +1058,91 @@ class BotBase(GroupMixin): await self.invoke(ctx) async def on_message(self, message): - await self.process_commands(message) + if self.message_commands: + await self.process_commands(message) + + async def on_interaction(self, interaction: discord.Interaction): + if not self.slash_commands or interaction.type != discord.InteractionType.application_command: + return + + assert interaction.user is not None + + interaction.data = cast(ApplicationCommandInteractionData, interaction.data) + + # Ensure the interaction channel is usable + channel = interaction.channel + if channel is None or isinstance(channel, discord.PartialMessageable): + if interaction.guild is None: + channel = await interaction.user.create_dm() + elif interaction.channel_id is not None: + channel = await interaction.guild.fetch_channel(interaction.channel_id) + else: + return # cannot do anything without stable channel + + # Fetch out subcommands from the options + command_name = interaction.data['name'] + command_options = interaction.data.get('options') or [] + for option in command_options: + if option['type'] in {1, 2}: + command_name = option['name'] + command_options = option.get('options') or [] + + command_name += f'{command_name} ' + + command = self.get_command(command_name) + if command is None: + raise errors.CommandNotFound(f'Command "{command_name}" is not found') + + message: discord.Message = _FakeSlashMessage(id=interaction.id, channel=channel) # type: ignore + message.author = interaction.user + + # Fetch a valid prefix, so process_commands can function + prefix = await self.get_prefix(message) + if isinstance(prefix, list): + prefix = prefix[0] + + # Add arguments to fake message content, in the right order + message.content = f'{prefix}{command_name} ' + for name, param in command.clean_params.items(): + option = next((o for o in command_options if o['name'] == name), None) # type: ignore + print(name, param, option) + + if option is None: + if not command._is_typing_optional(param.annotation): + raise errors.MissingRequiredArgument(param) + elif ( + option["type"] == 3 + and " " in option["value"] # type: ignore + and param.kind != param.KEYWORD_ONLY + and not isinstance(param.annotation, Greedy) + ): + # String with space in without "consume rest" + option = cast(_ApplicationCommandInteractionDataOptionString, option) + + # we need to quote this string otherwise we may spill into + # other parameters and cause all kinds of trouble, as many + # quotes are supported and some may be in the option, we + # loop through all supported quotes and if neither open or + # close are in the string, we add them + quoted = False + string = option['value'] + for open, close in supported_quotes.items(): + if not (open in string or close in string): + message.content += f"{open}{string}{close} " + quoted = True + break + + # all supported quotes are in the message and we cannot add any + # safely, very unlikely but still got to be covered + if not quoted: + raise errors.UnexpectedQuoteError(string) + else: + message.content += f'{option.get("value", "")} ' + + ctx = await self.get_context(message) + ctx.interaction = interaction + await self.invoke(ctx) + class Bot(BotBase, discord.Client): """Represents a discord bot. @@ -1103,7 +1214,20 @@ class Bot(BotBase, discord.Client): .. versionadded:: 1.7 """ - pass + # Needs to be moved to somewhere else, preferably BotBase + async def login(self, token: str) -> None: + await super().login(token=token) + await self._ready_commands() + + async def _ready_commands(self): + if not self.slash_commands: + return + + application = self.application_id or (await self.application_info()).id + commands = [scmd for cmd in self.commands if (scmd := cmd.to_application_command()) is not None] + + await self.http.bulk_upsert_guild_commands(application, self.slash_command_guild, payload=commands) + class AutoShardedBot(BotBase, discord.AutoShardedClient): """This is similar to :class:`.Bot` except that it is inherited from diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index 38a24d1d..a751c6bb 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -41,6 +41,7 @@ if TYPE_CHECKING: from discord.member import Member from discord.state import ConnectionState from discord.user import ClientUser, User + from discord.interactions import Interaction from discord.voice_client import VoiceProtocol from .bot import Bot, AutoShardedBot @@ -121,6 +122,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): A boolean that indicates if the command failed to be parsed, checked, or invoked. """ + interaction: Optional[Interaction] = None def __init__(self, *, diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index 35b7e840..cb411f2c 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -23,6 +23,7 @@ DEALINGS IN THE SOFTWARE. """ from __future__ import annotations + from typing import ( Any, Callable, @@ -44,6 +45,7 @@ import asyncio import functools import inspect import datetime +from operator import itemgetter import discord @@ -59,6 +61,7 @@ if TYPE_CHECKING: from typing_extensions import Concatenate, ParamSpec, TypeGuard from discord.message import Message + from discord.types.interactions import EditApplicationCommand from ._types import ( Coro, @@ -106,6 +109,16 @@ ContextT = TypeVar('ContextT', bound='Context') GroupT = TypeVar('GroupT', bound='Group') HookT = TypeVar('HookT', bound='Hook') ErrorT = TypeVar('ErrorT', bound='Error') +application_option_type_lookup = { + str: 3, + bool: 5, + int: 4, + (discord.Member, discord.User): 6, # Preferably discord.abc.User, but 'Protocols with non-method members don't support issubclass()' + (discord.abc.GuildChannel, discord.DMChannel): 7, + discord.Role: 8, + discord.Object: 9, + float: 10 +} if TYPE_CHECKING: P = ParamSpec('P') @@ -269,8 +282,8 @@ class Command(_BaseCommand, Generic[CogT, P, T]): which calls converters. If ``False`` then cooldown processing is done first and then the converters are called second. Defaults to ``False``. extras: :class:`dict` - A dict of user provided extras to attach to the Command. - + A dict of user provided extras to attach to the Command. + .. note:: This object may be copied by the library. @@ -309,6 +322,8 @@ class Command(_BaseCommand, Generic[CogT, P, T]): self.callback = func self.enabled: bool = kwargs.get('enabled', True) + self.slash_command: Optional[bool] = kwargs.get("slash_command", None) + self.normal_command: Optional[bool] = kwargs.get("normal_command", None) help_doc = kwargs.get('help') if help_doc is not None: @@ -344,7 +359,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): cooldown = func.__commands_cooldown__ except AttributeError: cooldown = kwargs.get('cooldown') - + if cooldown is None: buckets = CooldownMapping(cooldown, BucketType.default) elif isinstance(cooldown, CooldownMapping): @@ -1098,7 +1113,13 @@ class Command(_BaseCommand, Generic[CogT, P, T]): A boolean indicating if the command can be invoked. """ - if not self.enabled: + if not self.enabled or ( + ctx.interaction is not None + and self.slash_command is False + ) or ( + ctx.interaction is None + and self.normal_command is False + ): raise DisabledCommand(f'{self.name} command is disabled') original = ctx.command @@ -1125,6 +1146,54 @@ class Command(_BaseCommand, Generic[CogT, P, T]): finally: ctx.command = original + def to_application_command(self) -> Optional[EditApplicationCommand]: + if self.slash_command is False: + return + + payload = { + "name": self.name, + "description": self.short_doc or "no description", + "options": [] + } + + option_descriptions = self.extras.get("option_descriptions", {}) + for name, param in self.clean_params.items(): + annotation: Type[Any] = param.annotation if param.annotation is not param.empty else str + origin = getattr(param.annotation, "__origin__", None) + + if origin is None and isinstance(annotation, Greedy): + annotation = annotation.converter + origin = Greedy + + option: Dict[str, Any] = { + "name": name, + "required": not self._is_typing_optional(annotation), + "description": option_descriptions.get(name, "no description"), + } + + if not option["required"] and origin is not None and len(annotation.__args__) == 2: + # Unpack Optional[T] (Union[T, None]) into just T + annotation, origin = annotation.__args__[0], None + + if origin is None: + option["type"] = next( + (num for t, num in application_option_type_lookup.items() + if issubclass(annotation, t)), str + ) + elif origin is Literal and len(origin.__args__) <= 25: # type: ignore + option["choices"] = [{ + "name": literal_value, + "value": literal_value + } for literal_value in origin.__args__] # type: ignore + else: + option["type"] = 3 # STRING + + payload["options"].append(option) + + # Now we have all options, make sure required is before optional. + payload["options"] = sorted(payload["options"], key=itemgetter("required"), reverse=True) + return payload # type: ignore + class GroupMixin(Generic[CogT]): """A mixin that implements common functionality for classes that behave similar to :class:`.Group` and are allowed to register commands. diff --git a/discord/ext/commands/view.py b/discord/ext/commands/view.py index a7dc7236..a613dbfe 100644 --- a/discord/ext/commands/view.py +++ b/discord/ext/commands/view.py @@ -25,7 +25,7 @@ DEALINGS IN THE SOFTWARE. from .errors import UnexpectedQuoteError, InvalidEndOfQuotedStringError, ExpectedClosingQuoteError # map from opening quotes to closing quotes -_quotes = { +supported_quotes = { '"': '"', "‘": "’", "‚": "‛", @@ -44,7 +44,7 @@ _quotes = { "《": "》", "〈": "〉", } -_all_quotes = set(_quotes.keys()) | set(_quotes.values()) +_all_quotes = set(supported_quotes.keys()) | set(supported_quotes.values()) class StringView: def __init__(self, buffer): @@ -129,7 +129,7 @@ class StringView: if current is None: return None - close_quote = _quotes.get(current) + close_quote = supported_quotes.get(current) is_quoted = bool(close_quote) if is_quoted: result = [] diff --git a/discord/interactions.py b/discord/interactions.py index b89d49f5..f4849a2f 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -58,11 +58,11 @@ if TYPE_CHECKING: from aiohttp import ClientSession from .embeds import Embed from .ui.view import View - from .channel import VoiceChannel, StageChannel, TextChannel, CategoryChannel, StoreChannel, PartialMessageable + from .channel import TextChannel, CategoryChannel, StoreChannel, PartialMessageable from .threads import Thread InteractionChannel = Union[ - VoiceChannel, StageChannel, TextChannel, CategoryChannel, StoreChannel, Thread, PartialMessageable + TextChannel, CategoryChannel, StoreChannel, Thread, PartialMessageable ] MISSING: Any = utils.MISSING @@ -179,7 +179,7 @@ class Interaction: type = ChannelType.text if self.guild_id is not None else ChannelType.private return PartialMessageable(state=self._state, id=self.channel_id, type=type) return None - return channel + return channel # type: ignore @property def permissions(self) -> Permissions: diff --git a/discord/opus.py b/discord/opus.py index 97d437a3..16bf1384 100644 --- a/discord/opus.py +++ b/discord/opus.py @@ -428,7 +428,7 @@ class Decoder(_OpusStruct): @overload def decode(self, data: bytes, *, fec: bool) -> bytes: ... - + @overload def decode(self, data: Literal[None], *, fec: Literal[False]) -> bytes: ... diff --git a/discord/template.py b/discord/template.py index 30af3a4d..449a0110 100644 --- a/discord/template.py +++ b/discord/template.py @@ -310,7 +310,7 @@ class Template: @property def url(self) -> str: """:class:`str`: The template url. - + .. versionadded:: 2.0 """ return f'https://discord.new/{self.code}' diff --git a/discord/threads.py b/discord/threads.py index 892910d9..c49e8f78 100644 --- a/discord/threads.py +++ b/discord/threads.py @@ -273,7 +273,7 @@ class Thread(Messageable, Hashable): if parent is None: raise ClientException('Parent channel not found') return parent.category - + @property def category_id(self) -> Optional[int]: """The category channel ID the parent channel belongs to, if applicable. diff --git a/discord/types/interactions.py b/discord/types/interactions.py index b0ce156b..74f58a17 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -229,8 +229,7 @@ class _EditApplicationCommandOptional(TypedDict, total=False): description: str options: Optional[List[ApplicationCommandOption]] type: ApplicationCommandType - + default_permission: bool class EditApplicationCommand(_EditApplicationCommandOptional): name: str - default_permission: bool diff --git a/discord/voice_client.py b/discord/voice_client.py index d382a74d..eba4f47c 100644 --- a/discord/voice_client.py +++ b/discord/voice_client.py @@ -66,7 +66,7 @@ if TYPE_CHECKING: VoiceServerUpdate as VoiceServerUpdatePayload, SupportedModes, ) - + has_nacl: bool diff --git a/docs/conf.py b/docs/conf.py index 03f69c19..5a03014c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -355,7 +355,7 @@ texinfo_documents = [ #texinfo_no_detailmenu = False def setup(app): - if app.config.language == 'ja': - app.config.intersphinx_mapping['py'] = ('https://docs.python.org/ja/3', None) - app.config.html_context['discord_invite'] = 'https://discord.gg/nXzj3dg' - app.config.resource_links['discord'] = 'https://discord.gg/nXzj3dg' + if app.config.language == 'ja': + app.config.intersphinx_mapping['py'] = ('https://docs.python.org/ja/3', None) + app.config.html_context['discord_invite'] = 'https://discord.gg/nXzj3dg' + app.config.resource_links['discord'] = 'https://discord.gg/nXzj3dg' diff --git a/docs/extensions/details.py b/docs/extensions/details.py index 96f39d5b..ba6f5b70 100644 --- a/docs/extensions/details.py +++ b/docs/extensions/details.py @@ -52,4 +52,3 @@ def setup(app): app.add_node(details, html=(visit_details_node, depart_details_node)) app.add_node(summary, html=(visit_summary_node, depart_summary_node)) app.add_directive('details', DetailsDirective) - diff --git a/docs/extensions/nitpick_file_ignorer.py b/docs/extensions/nitpick_file_ignorer.py index f5dff1d1..dda44c9c 100644 --- a/docs/extensions/nitpick_file_ignorer.py +++ b/docs/extensions/nitpick_file_ignorer.py @@ -5,7 +5,7 @@ from sphinx.util import logging as sphinx_logging class NitpickFileIgnorer(logging.Filter): - + def __init__(self, app: Sphinx) -> None: self.app = app super().__init__() diff --git a/examples/converters.py b/examples/converters.py index 9bd8ae06..1e5cf7e7 100644 --- a/examples/converters.py +++ b/examples/converters.py @@ -78,7 +78,7 @@ class ChannelOrMemberConverter(commands.Converter): async def notify(ctx: commands.Context, target: ChannelOrMemberConverter): # This command signature utilises the custom converter written above # What will happen during command invocation is that the `target` above will be passed to - # the `argument` parameter of the `ChannelOrMemberConverter.convert` method and + # the `argument` parameter of the `ChannelOrMemberConverter.convert` method and # the conversion will go through the process defined there. await target.send(f'Hello, {target.name}!') diff --git a/examples/custom_context.py b/examples/custom_context.py index d3a5b94b..e970c2b9 100644 --- a/examples/custom_context.py +++ b/examples/custom_context.py @@ -27,7 +27,7 @@ class MyBot(commands.Bot): # subclass to the super() method, which tells the bot to # use the new MyContext class return await super().get_context(message, cls=cls) - + bot = MyBot(command_prefix='!') @@ -43,7 +43,7 @@ async def guess(ctx, number: int): await ctx.tick(number == value) # IMPORTANT: You shouldn't hard code your token -# these are very important, and leaking them can +# these are very important, and leaking them can # let people do very malicious things with your # bot. Try to use a file or something to keep # them private, and don't commit it to GitHub diff --git a/examples/secret.py b/examples/secret.py index 9246c68f..a12e8978 100644 --- a/examples/secret.py +++ b/examples/secret.py @@ -5,7 +5,7 @@ from discord.ext import commands bot = commands.Bot(command_prefix=commands.when_mentioned, description="Nothing to see here!") -# the `hidden` keyword argument hides it from the help command. +# the `hidden` keyword argument hides it from the help command. @bot.group(hidden=True) async def secret(ctx: commands.Context): """What is this "secret" you speak of?""" @@ -13,7 +13,7 @@ async def secret(ctx: commands.Context): await ctx.send('Shh!', delete_after=5) def create_overwrites(ctx, *objects): - """This is just a helper function that creates the overwrites for the + """This is just a helper function that creates the overwrites for the voice/text channels. A `discord.PermissionOverwrite` allows you to determine the permissions @@ -45,10 +45,10 @@ def create_overwrites(ctx, *objects): @secret.command() @commands.guild_only() async def text(ctx: commands.Context, name: str, *objects: typing.Union[discord.Role, discord.Member]): - """This makes a text channel with a specified name + """This makes a text channel with a specified name that is only visible to roles or members that are specified. """ - + overwrites = create_overwrites(ctx, *objects) await ctx.guild.create_text_channel( diff --git a/examples/views/dropdown.py b/examples/views/dropdown.py index db6d699a..40606481 100644 --- a/examples/views/dropdown.py +++ b/examples/views/dropdown.py @@ -24,7 +24,7 @@ class Dropdown(discord.ui.Select): async def callback(self, interaction: discord.Interaction): # Use the interaction object to send a response message containing # the user's favourite colour or choice. The self object refers to the - # Select object, and the values attribute gets a list of the user's + # Select object, and the values attribute gets a list of the user's # selected options. We only want the first one. await interaction.response.send_message(f'Your favourite colour is {self.values[0]}') @@ -44,8 +44,8 @@ class Bot(commands.Bot): async def on_ready(self): print(f'Logged in as {self.user} (ID: {self.user.id})') print('------') - - + + bot = Bot() diff --git a/setup.py b/setup.py index 9ffd24ce..be000875 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ import re requirements = [] with open('requirements.txt') as f: - requirements = f.read().splitlines() + requirements = f.read().splitlines() version = '' with open('discord/__init__.py') as f: -- 2.47.2 From 614c6bca6755df3adf94b5942d596ce30a64c955 Mon Sep 17 00:00:00 2001 From: Gnome Date: Tue, 31 Aug 2021 12:12:21 +0100 Subject: [PATCH 02/32] Implement a ctx.send helper for slash commands --- discord/ext/commands/bot.py | 6 +-- discord/ext/commands/context.py | 80 ++++++++++++++++++++++++++++++++- discord/interactions.py | 25 ++++++----- discord/ui/view.py | 2 +- 4 files changed, 95 insertions(+), 18 deletions(-) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 92c00ea3..a5a727d1 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -74,7 +74,7 @@ CXT = TypeVar('CXT', bound='Context') class _FakeSlashMessage(discord.PartialMessage): activity = application = edited_at = reference = webhook_id = None - attachments = components = reactions = stickers = [] + attachments = components = reactions = stickers = mentions = [] author: Union[discord.User, discord.Member] tts = False @@ -1066,7 +1066,6 @@ class BotBase(GroupMixin): return assert interaction.user is not None - interaction.data = cast(ApplicationCommandInteractionData, interaction.data) # Ensure the interaction channel is usable @@ -1105,7 +1104,6 @@ class BotBase(GroupMixin): message.content = f'{prefix}{command_name} ' for name, param in command.clean_params.items(): option = next((o for o in command_options if o['name'] == name), None) # type: ignore - print(name, param, option) if option is None: if not command._is_typing_optional(param.annotation): @@ -1224,7 +1222,7 @@ class Bot(BotBase, discord.Client): return application = self.application_id or (await self.application_info()).id - commands = [scmd for cmd in self.commands if (scmd := cmd.to_application_command()) is not None] + commands = [scmd for cmd in self.commands if not cmd.hidden and (scmd := cmd.to_application_command()) is not None] await self.http.bulk_upsert_guild_commands(application, self.slash_command_guild, payload=commands) diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index a751c6bb..2e05208c 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -25,8 +25,8 @@ from __future__ import annotations import inspect import re - -from typing import Any, Dict, Generic, List, Optional, TYPE_CHECKING, TypeVar, Union +from datetime import timedelta +from typing import Any, Dict, Generic, List, Literal, Optional, TYPE_CHECKING, TypeVar, Union, overload import discord.abc import discord.utils @@ -41,6 +41,7 @@ if TYPE_CHECKING: from discord.member import Member from discord.state import ConnectionState from discord.user import ClientUser, User + from discord.webhook import WebhookMessage from discord.interactions import Interaction from discord.voice_client import VoiceProtocol @@ -397,6 +398,81 @@ class Context(discord.abc.Messageable, Generic[BotT]): except CommandError as e: await cmd.on_help_command_error(self, e) + + @overload + async def send(self, + content: Optional[str] = None, + return_message: Literal[False] = False, + ephemeral: bool = False, **kwargs: Any + ) -> Optional[Union[Message, WebhookMessage]]: ... + @overload + async def send(self, + content: Optional[str] = None, + return_message: Literal[True] = True, + ephemeral: bool = False, **kwargs: Any + ) -> Union[Message, WebhookMessage]: ... + + async def send(self, + content: Optional[str] = None, + return_message: bool = True, + ephemeral: bool = False, **kwargs: Any + ) -> Optional[Union[Message, WebhookMessage]]: + """ + |coro| + + A shortcut method to :meth:`.abc.Messageable.send` with interaction helpers. + + This function takes all the parameters of :meth:`.abc.Messageable.send` plus the following: + + Parameters + ------------ + return_message: :class:`bool` + Ignored if not in a slash command context. + If this is set to False more native interaction methods will be used. + ephemeral: :class:`bool` + Ignored if not in a slash command context. + Indicates if the message should only be visible to the user who started the interaction. + If a view is sent with an ephemeral message and it has no timeout set then the timeout + is set to 15 minutes. + + Returns + -------- + Optional[Union[:class:`.Message`, :class:`.WebhookMessage`]] + In a slash command context, the message that was sent if return_message is True. + + In a normal context, it always returns a :class:`.Message` + """ + + if ( + self.interaction is None + or ( + self.interaction.response.responded_at is not None + and discord.utils.utcnow() - self.interaction.response.responded_at >= timedelta(minutes=15) + )): + return await super().send(content, **kwargs) + + # Remove unsupported arguments from kwargs + kwargs.pop("nonce", None) + kwargs.pop("stickers", None) + kwargs.pop("reference", None) + kwargs.pop("delete_after", None) + kwargs.pop("mention_author", None) + + if not ( + return_message + or self.interaction.response.is_done() + or any(arg in kwargs for arg in ("file", "files", "allowed_mentions")) + ): + send = self.interaction.response.send_message + else: + # We have to defer in order to use the followup webhook + if not self.interaction.response.is_done(): + await self.interaction.response.defer(ephemeral=ephemeral) + + send = self.interaction.followup.send + + return await send(content, ephemeral=ephemeral, **kwargs) # type: ignore + @discord.utils.copy_doc(Message.reply) async def reply(self, content: Optional[str] = None, **kwargs: Any) -> Message: return await self.message.reply(content, **kwargs) diff --git a/discord/interactions.py b/discord/interactions.py index f4849a2f..c9f629d0 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -47,6 +47,8 @@ __all__ = ( ) if TYPE_CHECKING: + from datetime import datetime + from .types.interactions import ( Interaction as InteractionPayload, InteractionData, @@ -369,20 +371,20 @@ class InteractionResponse: """ __slots__: Tuple[str, ...] = ( - '_responded', + 'responded_at', '_parent', ) def __init__(self, parent: Interaction): + self.responded_at: Optional[datetime] = None self._parent: Interaction = parent - self._responded: bool = False def is_done(self) -> bool: """:class:`bool`: Indicates whether an interaction response has been done before. An interaction can only be responded to once. """ - return self._responded + return self.responded_at is not None async def defer(self, *, ephemeral: bool = False) -> None: """|coro| @@ -405,7 +407,7 @@ class InteractionResponse: InteractionResponded This interaction has already been responded to before. """ - if self._responded: + if self.is_done(): raise InteractionResponded(self._parent) defer_type: int = 0 @@ -423,7 +425,8 @@ class InteractionResponse: await adapter.create_interaction_response( parent.id, parent.token, session=parent._session, type=defer_type, data=data ) - self._responded = True + + self.responded_at = utils.utcnow() async def pong(self) -> None: """|coro| @@ -439,7 +442,7 @@ class InteractionResponse: InteractionResponded This interaction has already been responded to before. """ - if self._responded: + if self.is_done(): raise InteractionResponded(self._parent) parent = self._parent @@ -448,7 +451,7 @@ class InteractionResponse: await adapter.create_interaction_response( parent.id, parent.token, session=parent._session, type=InteractionResponseType.pong.value ) - self._responded = True + self.responded_at = utils.utcnow() async def send_message( self, @@ -494,7 +497,7 @@ class InteractionResponse: InteractionResponded This interaction has already been responded to before. """ - if self._responded: + if self.is_done(): raise InteractionResponded(self._parent) payload: Dict[str, Any] = { @@ -537,7 +540,7 @@ class InteractionResponse: self._parent._state.store_view(view) - self._responded = True + self.responded_at = utils.utcnow() async def edit_message( self, @@ -578,7 +581,7 @@ class InteractionResponse: InteractionResponded This interaction has already been responded to before. """ - if self._responded: + if self.is_done(): raise InteractionResponded(self._parent) parent = self._parent @@ -629,7 +632,7 @@ class InteractionResponse: if view and not view.is_finished(): state.store_view(view, message_id) - self._responded = True + self.responded_at = utils.utcnow() class _InteractionMessageState: diff --git a/discord/ui/view.py b/discord/ui/view.py index 13510eea..115d1ec4 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -357,7 +357,7 @@ class View: return await item.callback(interaction) - if not interaction.response._responded: + if not interaction.response.is_done(): await interaction.response.defer() except Exception as e: return await self.on_error(e, item, interaction) -- 2.47.2 From 1a22df62289816d5435456b97e942e3fe939d73e Mon Sep 17 00:00:00 2001 From: Gnome Date: Tue, 31 Aug 2021 15:17:49 +0100 Subject: [PATCH 03/32] Add group command support --- discord/ext/commands/bot.py | 11 +++++------ discord/ext/commands/core.py | 22 +++++++++++++++++++++- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index a5a727d1..36946c85 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -1081,12 +1081,11 @@ class BotBase(GroupMixin): # Fetch out subcommands from the options command_name = interaction.data['name'] command_options = interaction.data.get('options') or [] - for option in command_options: - if option['type'] in {1, 2}: - command_name = option['name'] - command_options = option.get('options') or [] - - command_name += f'{command_name} ' + while any(o["type"] in {1, 2} for o in command_options): + for option in command_options: + if option['type'] in {1, 2}: + command_name += f' {option["name"]}' + command_options = option.get('options') or [] command = self.get_command(command_name) if command is None: diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index cb411f2c..123a76d6 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -1146,15 +1146,19 @@ class Command(_BaseCommand, Generic[CogT, P, T]): finally: ctx.command = original - def to_application_command(self) -> Optional[EditApplicationCommand]: + def to_application_command(self, nested: int = 0) -> Optional[EditApplicationCommand]: if self.slash_command is False: return + elif nested == 3: + raise ValueError(f"{self.qualified_name} is too deeply nested!") payload = { "name": self.name, "description": self.short_doc or "no description", "options": [] } + if nested != 0: + payload["type"] = 1 option_descriptions = self.extras.get("option_descriptions", {}) for name, param in self.clean_params.items(): @@ -1561,6 +1565,22 @@ class Group(GroupMixin[CogT], Command[CogT, P, T]): view.previous = previous await super().reinvoke(ctx, call_hooks=call_hooks) + def to_application_command(self, nested: int = 0) -> Optional[EditApplicationCommand]: + if self.slash_command is False: + return + elif nested == 2: + raise ValueError(f"{self.qualified_name} is too deeply nested for slash commands!") + + return { # type: ignore + "name": self.name, + "type": int(not (nested - 1)) + 1, + "description": self.short_doc or 'no description', + "options": [ + cmd.to_application_command(nested=nested+1) + for cmd in self.commands + ] + } + # Decorators @overload -- 2.47.2 From 7c83c335d1f635c979bcdbcf9d646b2a9d3898f2 Mon Sep 17 00:00:00 2001 From: Gnome Date: Tue, 31 Aug 2021 18:44:32 +0100 Subject: [PATCH 04/32] Add Option converter, fix default optional, fix help command --- discord/ext/commands/bot.py | 2 +- discord/ext/commands/converter.py | 18 ++++++++++++++++++ discord/ext/commands/core.py | 24 ++++++++++++++++-------- discord/ext/commands/help.py | 12 ++++++++++-- 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 36946c85..46ccdee4 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -1105,7 +1105,7 @@ class BotBase(GroupMixin): option = next((o for o in command_options if o['name'] == name), None) # type: ignore if option is None: - if not command._is_typing_optional(param.annotation): + if param.default is param.empty and not command._is_typing_optional(param.annotation): raise errors.MissingRequiredArgument(param) elif ( option["type"] == 3 diff --git a/discord/ext/commands/converter.py b/discord/ext/commands/converter.py index 5740a188..bf552a59 100644 --- a/discord/ext/commands/converter.py +++ b/discord/ext/commands/converter.py @@ -77,6 +77,7 @@ __all__ = ( 'GuildStickerConverter', 'clean_content', 'Greedy', + 'Option', 'run_converters', ) @@ -96,6 +97,9 @@ T_co = TypeVar('T_co', covariant=True) CT = TypeVar('CT', bound=discord.abc.GuildChannel) TT = TypeVar('TT', bound=discord.Thread) +NT = TypeVar('NT', bound=str) +DT = TypeVar('DT', bound=str) + @runtime_checkable class Converter(Protocol[T_co]): @@ -1004,6 +1008,20 @@ class Greedy(List[T]): return cls(converter=converter) +if TYPE_CHECKING: + def Option(default: T = inspect.Parameter.empty, *, name: str = None, description: str) -> T: ... +else: + class Option(Generic[T, DT, NT]): + description: DT + name: Optional[NT] + default: Union[T, inspect.Parameter.empty] + __slots__ = ('name', 'default', 'description',) + + def __init__(self, default: T = inspect.Parameter.empty, *, name: NT = None, description: DT) -> None: + self.description = description + self.default = default + self.name = name + def _convert_to_bool(argument: str) -> bool: lowered = argument.lower() diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index 123a76d6..83730243 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -39,19 +39,21 @@ from typing import ( TypeVar, Type, TYPE_CHECKING, + cast, overload, ) import asyncio import functools import inspect import datetime +from collections import defaultdict from operator import itemgetter import discord from .errors import * from .cooldowns import Cooldown, BucketType, CooldownMapping, MaxConcurrency, DynamicCooldownMapping -from .converter import run_converters, get_converter, Greedy +from .converter import run_converters, get_converter, Greedy, Option from ._types import _BaseCommand from .cog import Cog from .context import Context @@ -136,13 +138,19 @@ def unwrap_function(function: Callable[..., Any]) -> Callable[..., Any]: return function -def get_signature_parameters(function: Callable[..., Any], globalns: Dict[str, Any]) -> Dict[str, inspect.Parameter]: +def get_signature_parameters(function: Callable[..., Any], globalns: Dict[str, Any]) -> Tuple[Dict[str, inspect.Parameter], Dict[str, str]]: signature = inspect.signature(function) params = {} cache: Dict[str, Any] = {} + descriptions = defaultdict(lambda: 'no description') eval_annotation = discord.utils.evaluate_annotation for name, parameter in signature.parameters.items(): annotation = parameter.annotation + if isinstance(parameter.default, Option): # type: ignore + option = parameter.default + descriptions[name] = option.description + parameter = parameter.replace(default=option.default) + if annotation is parameter.empty: params[name] = parameter continue @@ -156,7 +164,7 @@ def get_signature_parameters(function: Callable[..., Any], globalns: Dict[str, A params[name] = parameter.replace(annotation=annotation) - return params + return params, descriptions def wrap_callback(coro): @@ -421,7 +429,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): except AttributeError: globalns = {} - self.params = get_signature_parameters(function, globalns) + self.params, self.option_descriptions = get_signature_parameters(function, globalns) def add_check(self, func: Check) -> None: """Adds a check to the command. @@ -1160,7 +1168,6 @@ class Command(_BaseCommand, Generic[CogT, P, T]): if nested != 0: payload["type"] = 1 - option_descriptions = self.extras.get("option_descriptions", {}) for name, param in self.clean_params.items(): annotation: Type[Any] = param.annotation if param.annotation is not param.empty else str origin = getattr(param.annotation, "__origin__", None) @@ -1171,10 +1178,11 @@ class Command(_BaseCommand, Generic[CogT, P, T]): option: Dict[str, Any] = { "name": name, - "required": not self._is_typing_optional(annotation), - "description": option_descriptions.get(name, "no description"), + "description": self.option_descriptions[name], + "required": param.default is param.empty and not self._is_typing_optional(annotation), } + annotation = cast(Any, annotation) if not option["required"] and origin is not None and len(annotation.__args__) == 2: # Unpack Optional[T] (Union[T, None]) into just T annotation, origin = annotation.__args__[0], None @@ -1182,7 +1190,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): if origin is None: option["type"] = next( (num for t, num in application_option_type_lookup.items() - if issubclass(annotation, t)), str + if issubclass(annotation, t)), 3 ) elif origin is Literal and len(origin.__args__) <= 25: # type: ignore option["choices"] = [{ diff --git a/discord/ext/commands/help.py b/discord/ext/commands/help.py index afaacbfb..722ccc05 100644 --- a/discord/ext/commands/help.py +++ b/discord/ext/commands/help.py @@ -615,7 +615,7 @@ class HelpCommand: :class:`.abc.Messageable` The destination where the help command will be output. """ - return self.context.channel + return self.context async def send_error_message(self, error): """|coro| @@ -977,6 +977,14 @@ class DefaultHelpCommand(HelpCommand): for page in self.paginator.pages: await destination.send(page) + interaction = self.context.interaction + if ( + interaction is not None + and destination == self.context.author + and not interaction.response.is_done() + ): + await interaction.response.send_message("Sent help to your DMs!", ephemeral=True) + def add_command_formatting(self, command): """A utility function to format the non-indented block of commands and groups. @@ -1007,7 +1015,7 @@ class DefaultHelpCommand(HelpCommand): elif self.dm_help is None and len(self.paginator) > self.dm_help_threshold: return ctx.author else: - return ctx.channel + return ctx async def prepare_help_command(self, ctx, command): self.paginator.clear() -- 2.47.2 From 2cdf4b86c5230f2c7fedbd005558ac3c048a08d3 Mon Sep 17 00:00:00 2001 From: Gnome Date: Tue, 31 Aug 2021 19:13:32 +0100 Subject: [PATCH 05/32] Add client.setup and move readying commands to that --- discord/client.py | 13 ++++++++++++- discord/ext/commands/bot.py | 31 +++++++++++++++++++------------ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/discord/client.py b/discord/client.py index 250e9da1..b09791a9 100644 --- a/discord/client.py +++ b/discord/client.py @@ -595,7 +595,7 @@ class Client: async def start(self, token: str, *, reconnect: bool = True) -> None: """|coro| - A shorthand coroutine for :meth:`login` + :meth:`connect`. + A shorthand coroutine for :meth:`login` + :meth:`setup` + :meth:`connect`. Raises ------- @@ -603,8 +603,19 @@ class Client: An unexpected keyword argument was received. """ await self.login(token) + await self.setup() await self.connect(reconnect=reconnect) + async def setup(self) -> Any: + """|coro| + + A coroutine to be called to setup the bot, by default this is blank. + + To perform asynchronous setup after the bot is logged in but before + it has connected to the Websocket, overwrite this coroutine. + """ + pass + def run(self, *args: Any, **kwargs: Any) -> None: """A blocking call that abstracts away the event loop initialisation from you. diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 46ccdee4..faf6f989 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -28,6 +28,7 @@ from __future__ import annotations import asyncio import collections import collections.abc +from discord.http import HTTPClient import inspect import importlib.util @@ -166,7 +167,7 @@ class BotBase(GroupMixin): if not (message_commands or slash_commands): raise TypeError("Both message_commands and slash_commands are disabled.") elif slash_commands: - self.slash_command_guild = options['slash_command_guild'] + self.slash_command_guild = options.get('slash_command_guild', None) if help_command is _default: self.help_command = DefaultHelpCommand() @@ -183,6 +184,15 @@ class BotBase(GroupMixin): for event in self.extra_events.get(ev, []): self._schedule_event(event, ev, *args, **kwargs) # type: ignore + async def _create_application_commands(self, application_id: int, http: HTTPClient): + commands = [scmd for cmd in self.commands if not cmd.hidden and (scmd := cmd.to_application_command()) is not None] + + if self.slash_command_guild is None: + await http.bulk_upsert_global_commands(application_id, payload=commands) + else: + await http.bulk_upsert_guild_commands(application_id, self.slash_command_guild, payload=commands) + + @discord.utils.copy_doc(discord.Client.close) async def close(self) -> None: for extension in tuple(self.__extensions): @@ -1211,23 +1221,20 @@ class Bot(BotBase, discord.Client): .. versionadded:: 1.7 """ - # Needs to be moved to somewhere else, preferably BotBase - async def login(self, token: str) -> None: - await super().login(token=token) - await self._ready_commands() - - async def _ready_commands(self): + async def setup(self): if not self.slash_commands: return application = self.application_id or (await self.application_info()).id - commands = [scmd for cmd in self.commands if not cmd.hidden and (scmd := cmd.to_application_command()) is not None] - - await self.http.bulk_upsert_guild_commands(application, self.slash_command_guild, payload=commands) - + await self._create_application_commands(application, self.http) class AutoShardedBot(BotBase, discord.AutoShardedClient): """This is similar to :class:`.Bot` except that it is inherited from :class:`discord.AutoShardedClient` instead. """ - pass + async def setup(self): + if not self.slash_commands: + return + + application = self.application_id or (await self.application_info()).id + await self._create_application_commands(application, self.http) -- 2.47.2 From 5a49e1bf684979b2e6d32d0a54e62bca258d0238 Mon Sep 17 00:00:00 2001 From: Gnome Date: Wed, 1 Sep 2021 11:51:45 +0100 Subject: [PATCH 06/32] Implement _FakeSlashMessage.from_interaction --- discord/ext/commands/bot.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index faf6f989..230996ef 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -79,6 +79,13 @@ class _FakeSlashMessage(discord.PartialMessage): author: Union[discord.User, discord.Member] tts = False + @classmethod + def from_interaction(cls, interaction: discord.Interaction) -> discord.Message: + self: discord.Message = cls(channel=interaction.channel, id=interaction.id) # type: ignore + + assert interaction.user is not None + self.author = interaction.user + return self def when_mentioned(bot: Union[Bot, AutoShardedBot], msg: Message) -> List[str]: """A callable that implements a command prefix equivalent to being mentioned. @@ -1088,6 +1095,9 @@ class BotBase(GroupMixin): else: return # cannot do anything without stable channel + interaction.channel = channel # type: ignore + del channel + # Fetch out subcommands from the options command_name = interaction.data['name'] command_options = interaction.data.get('options') or [] @@ -1101,10 +1111,8 @@ class BotBase(GroupMixin): if command is None: raise errors.CommandNotFound(f'Command "{command_name}" is not found') - message: discord.Message = _FakeSlashMessage(id=interaction.id, channel=channel) # type: ignore - message.author = interaction.user - # Fetch a valid prefix, so process_commands can function + message = _FakeSlashMessage.from_interaction(interaction) prefix = await self.get_prefix(message) if isinstance(prefix, list): prefix = prefix[0] -- 2.47.2 From 667c8e7caa80021ee6cb4b1227f0c4ae6320170f Mon Sep 17 00:00:00 2001 From: Gnome Date: Wed, 1 Sep 2021 12:45:39 +0100 Subject: [PATCH 07/32] Rename normmal_command to message_command --- discord/ext/commands/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index 83730243..876967cc 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -331,7 +331,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): self.callback = func self.enabled: bool = kwargs.get('enabled', True) self.slash_command: Optional[bool] = kwargs.get("slash_command", None) - self.normal_command: Optional[bool] = kwargs.get("normal_command", None) + self.message_command: Optional[bool] = kwargs.get("message_command", None) help_doc = kwargs.get('help') if help_doc is not None: @@ -1126,7 +1126,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): and self.slash_command is False ) or ( ctx.interaction is None - and self.normal_command is False + and self.message_command is False ): raise DisabledCommand(f'{self.name} command is disabled') -- 2.47.2 From 6faea43c99cf2c92fce372d668a47c37539f0296 Mon Sep 17 00:00:00 2001 From: Gnome Date: Wed, 1 Sep 2021 14:44:24 +0100 Subject: [PATCH 08/32] Add docs for added params --- discord/ext/commands/bot.py | 14 ++++++++++++++ discord/ext/commands/core.py | 14 ++++++++++++++ docs/whats_new.rst | 2 +- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 230996ef..d901586e 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -1228,6 +1228,20 @@ class Bot(BotBase, discord.Client): the ``command_prefix`` is set to ``!``. Defaults to ``False``. .. versionadded:: 1.7 + message_commands: Optional[:class:`bool`] + Whether to process commands based on messages. + + Can be overwritten per command in the command decorators or when making + a :class:`Command` object via the ``message_command`` parameter + + .. versionadded:: 2.0 + slash_commands: Optional[:class:`bool`] + Whether to upload and process slash commands. + + Can be overwritten per command in the command decorators or when making + a :class:`Command` object via the ``slash_command`` parameter + + .. versionadded:: 2.0 """ async def setup(self): if not self.slash_commands: diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index 876967cc..098c9440 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -292,11 +292,25 @@ class Command(_BaseCommand, Generic[CogT, P, T]): extras: :class:`dict` A dict of user provided extras to attach to the Command. + .. versionadded:: 2.0 + .. note:: This object may be copied by the library. + message_command: Optional[:class:`bool`] + Whether to process this command based on messages. + + This overwrites the global ``message_commands`` parameter of :class:`.Bot`. .. versionadded:: 2.0 + + slash_command: Optional[:class:`bool`] + Whether to upload and process this command as a slash command. + + This overwrites the global ``slash_commands`` parameter of :class:`.Bot`. + + .. versionadded:: 2.0 + """ __original_kwargs__: Dict[str, Any] diff --git a/docs/whats_new.rst b/docs/whats_new.rst index bdbf1d75..bc283d22 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -43,7 +43,7 @@ Breaking Changes - :attr:`GroupChannel.owner` is now Optional - ``edit`` methods now only accept None if it actually means something (e.g. clearing it) - ``timeout`` parameter for ``ui.View.__init__`` is now keyword only -- When an interaction has already been responded and another one is sent, :exc:`InteractionResponded`is now raised. +- When an interaction has already been responded and another one is sent, :exc:`InteractionResponded` is now raised. - Discord's API only allows a single :attr:`interaction.response`. - Separate :func:`on_member_update` and :func:`on_presence_update` - The new event :func:`on_presence_update` is now called when status/activity is changed. -- 2.47.2 From f1d7d353b5f47fbb5f1f6187c5074e5e771f0b21 Mon Sep 17 00:00:00 2001 From: Gnome Date: Wed, 1 Sep 2021 17:00:37 +0100 Subject: [PATCH 09/32] Add slash_command_guilds to bot and decos --- discord/ext/commands/bot.py | 56 +++++++++++++++++++++++++++++++----- discord/ext/commands/core.py | 12 ++++++-- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index d901586e..75ca3e2d 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -35,11 +35,13 @@ import importlib.util import sys import traceback import types -from typing import Any, Callable, cast, Mapping, List, Dict, TYPE_CHECKING, Optional, TypeVar, Type, Union +from collections import defaultdict +from typing import Any, Callable, Iterable, cast, Mapping, List, Dict, TYPE_CHECKING, Optional, TypeVar, Type, Union import discord from discord.types.interactions import ( ApplicationCommandInteractionData, + EditApplicationCommand, _ApplicationCommandInteractionDataOptionString ) @@ -174,7 +176,7 @@ class BotBase(GroupMixin): if not (message_commands or slash_commands): raise TypeError("Both message_commands and slash_commands are disabled.") elif slash_commands: - self.slash_command_guild = options.get('slash_command_guild', None) + self.slash_command_guilds: Optional[Iterable[int]] = options.get('slash_command_guilds', None) if help_command is _default: self.help_command = DefaultHelpCommand() @@ -192,12 +194,44 @@ class BotBase(GroupMixin): self._schedule_event(event, ev, *args, **kwargs) # type: ignore async def _create_application_commands(self, application_id: int, http: HTTPClient): - commands = [scmd for cmd in self.commands if not cmd.hidden and (scmd := cmd.to_application_command()) is not None] + commands: defaultdict[Optional[int], List[EditApplicationCommand]] = defaultdict(list) + for command in self.commands: + if command.hidden: + continue - if self.slash_command_guild is None: - await http.bulk_upsert_global_commands(application_id, payload=commands) - else: - await http.bulk_upsert_guild_commands(application_id, self.slash_command_guild, payload=commands) + payload = command.to_application_command() + if payload is None: + continue + + guilds = command.slash_command_guilds or self.slash_command_guilds + if guilds is None: + commands[None].append(payload) + else: + for guild in guilds: + commands[guild].append(payload) + + global_commands = commands.pop(None, None) + if global_commands is not None: + if self.slash_command_guilds is None: + await http.bulk_upsert_global_commands( + payload=global_commands, + application_id=application_id, + ) + else: + for guild in self.slash_command_guilds: + await http.bulk_upsert_guild_commands( + guild_id=guild, + payload=global_commands, + application_id=application_id, + ) + + for guild, guild_commands in commands.items(): + assert guild is not None + await http.bulk_upsert_guild_commands( + guild_id=guild, + payload=guild_commands, + application_id=application_id, + ) @discord.utils.copy_doc(discord.Client.close) @@ -1242,6 +1276,14 @@ class Bot(BotBase, discord.Client): a :class:`Command` object via the ``slash_command`` parameter .. versionadded:: 2.0 + slash_command_guilds: Optional[:class:`List[int]`] + If this is set, only upload slash commands to these guild IDs. + + Can be overwritten per command in the command decorators or when making + a :class:`Command` object via the ``slash_command_guilds`` parameter + + .. versionadded:: 2.0 + """ async def setup(self): if not self.slash_commands: diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index 098c9440..c672d478 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -30,6 +30,7 @@ from typing import ( Dict, Generator, Generic, + Iterable, Literal, List, Optional, @@ -296,21 +297,24 @@ class Command(_BaseCommand, Generic[CogT, P, T]): .. note:: This object may be copied by the library. - message_command: Optional[:class:`bool`] Whether to process this command based on messages. This overwrites the global ``message_commands`` parameter of :class:`.Bot`. .. versionadded:: 2.0 - slash_command: Optional[:class:`bool`] Whether to upload and process this command as a slash command. This overwrites the global ``slash_commands`` parameter of :class:`.Bot`. .. versionadded:: 2.0 + slash_command_guilds: Optional[:class:`List[int]`] + If this is set, only upload this slash command to these guild IDs. + This overwrites the global ``slash_command_guilds`` parameter of :class:`.Bot`. + + .. versionadded:: 2.0 """ __original_kwargs__: Dict[str, Any] @@ -344,8 +348,10 @@ class Command(_BaseCommand, Generic[CogT, P, T]): self.callback = func self.enabled: bool = kwargs.get('enabled', True) + self.slash_command: Optional[bool] = kwargs.get("slash_command", None) self.message_command: Optional[bool] = kwargs.get("message_command", None) + self.slash_command_guilds: Optional[Iterable[int]] = kwargs.get("slash_command_guilds", None) help_doc = kwargs.get('help') if help_doc is not None: @@ -405,6 +411,8 @@ class Command(_BaseCommand, Generic[CogT, P, T]): # bandaid for the fact that sometimes parent can be the bot instance parent = kwargs.get('parent') self.parent: Optional[GroupMixin] = parent if isinstance(parent, _BaseCommand) else None # type: ignore + if self.slash_command_guilds is not None and self.parent is not None: + raise TypeError("Cannot set specific guilds for a subcommand. They are inherited from the top level group.") self._before_invoke: Optional[Hook] = None try: -- 2.47.2 From a6d6472c79fa50bd95c7133476d3565f2a82bf58 Mon Sep 17 00:00:00 2001 From: Gnome Date: Wed, 1 Sep 2021 17:45:25 +0100 Subject: [PATCH 10/32] Fix merge conflict --- discord/ext/commands/bot.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 75ca3e2d..2bda7313 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -146,11 +146,13 @@ class BotBase(GroupMixin): def __init__(self, command_prefix, help_command=_default, - description=None, + description=None, *, + intents: discord.Intents, message_commands: bool = True, slash_commands: bool = False, **options ): - super().__init__(**options) + super().__init__(**options, intents=intents) + self.command_prefix = command_prefix self.slash_commands = slash_commands self.message_commands = message_commands -- 2.47.2 From 84b1d7d0cdce9160916409f59f7a0e0171d7ca9a Mon Sep 17 00:00:00 2001 From: Gnome Date: Wed, 1 Sep 2021 18:53:50 +0100 Subject: [PATCH 11/32] Remove name from commands.Option, wasn't used --- discord/ext/commands/converter.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/discord/ext/commands/converter.py b/discord/ext/commands/converter.py index bf552a59..7d46b693 100644 --- a/discord/ext/commands/converter.py +++ b/discord/ext/commands/converter.py @@ -97,7 +97,6 @@ T_co = TypeVar('T_co', covariant=True) CT = TypeVar('CT', bound=discord.abc.GuildChannel) TT = TypeVar('TT', bound=discord.Thread) -NT = TypeVar('NT', bound=str) DT = TypeVar('DT', bound=str) @@ -1009,19 +1008,16 @@ class Greedy(List[T]): return cls(converter=converter) if TYPE_CHECKING: - def Option(default: T = inspect.Parameter.empty, *, name: str = None, description: str) -> T: ... + def Option(default: T = inspect.Parameter.empty, *, description: str) -> T: ... else: - class Option(Generic[T, DT, NT]): + class Option(Generic[T, DT]): description: DT - name: Optional[NT] default: Union[T, inspect.Parameter.empty] - __slots__ = ('name', 'default', 'description',) + __slots__ = ('default', 'description',) - def __init__(self, default: T = inspect.Parameter.empty, *, name: NT = None, description: DT) -> None: + def __init__(self, default: T = inspect.Parameter.empty, *, description: DT) -> None: self.description = description self.default = default - self.name = name - def _convert_to_bool(argument: str) -> bool: lowered = argument.lower() -- 2.47.2 From caa5f39c0f0a8d47f6ff13a757a0f7756f59b9a7 Mon Sep 17 00:00:00 2001 From: Gnome Date: Wed, 1 Sep 2021 21:10:54 +0100 Subject: [PATCH 12/32] Move slash command processing to BotBase.process_slash_commands --- discord/ext/commands/bot.py | 59 +++++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 2bda7313..72ece175 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -36,16 +36,17 @@ import sys import traceback import types from collections import defaultdict -from typing import Any, Callable, Iterable, cast, Mapping, List, Dict, TYPE_CHECKING, Optional, TypeVar, Type, Union +from typing import Any, Callable, Iterable, Tuple, cast, Mapping, List, Dict, TYPE_CHECKING, Optional, TypeVar, Type, Union import discord from discord.types.interactions import ( ApplicationCommandInteractionData, + ApplicationCommandInteractionDataOption, EditApplicationCommand, _ApplicationCommandInteractionDataOptionString ) -from .core import GroupMixin +from .core import Command, GroupMixin from .converter import Greedy from .view import StringView, supported_quotes from .context import Context @@ -136,6 +137,18 @@ def when_mentioned_or(*prefixes: str) -> Callable[[Union[Bot, AutoShardedBot], M def _is_submodule(parent: str, child: str) -> bool: return parent == child or child.startswith(parent + ".") +def _unwrap_slash_groups(data: ApplicationCommandInteractionData) -> Tuple[str, List[ApplicationCommandInteractionDataOption]]: + command_name = data['name'] + command_options = data.get('options') or [] + while any(o["type"] in {1, 2} for o in command_options): # type: ignore + for option in command_options: # type: ignore + if option['type'] in {1, 2}: # type: ignore + command_name += f' {option["name"]}' # type: ignore + command_options = option.get('options') or [] + + return command_name, command_options + + class _DefaultRepr: def __repr__(self): return '' @@ -1110,21 +1123,22 @@ class BotBase(GroupMixin): ctx = await self.get_context(message) await self.invoke(ctx) - async def on_message(self, message): - if self.message_commands: - await self.process_commands(message) - async def on_interaction(self, interaction: discord.Interaction): - if not self.slash_commands or interaction.type != discord.InteractionType.application_command: - return - - assert interaction.user is not None + async def process_slash_commands(self, interaction: discord.Interaction): interaction.data = cast(ApplicationCommandInteractionData, interaction.data) + command_name, command_options = _unwrap_slash_groups(interaction.data) + + command = self.get_command(command_name) + if command is None: + raise errors.CommandNotFound(f'Command "{command_name}" is not found') + elif not command.slash_command: + return # Ensure the interaction channel is usable channel = interaction.channel if channel is None or isinstance(channel, discord.PartialMessageable): if interaction.guild is None: + assert interaction.user is not None channel = await interaction.user.create_dm() elif interaction.channel_id is not None: channel = await interaction.guild.fetch_channel(interaction.channel_id) @@ -1134,19 +1148,6 @@ class BotBase(GroupMixin): interaction.channel = channel # type: ignore del channel - # Fetch out subcommands from the options - command_name = interaction.data['name'] - command_options = interaction.data.get('options') or [] - while any(o["type"] in {1, 2} for o in command_options): - for option in command_options: - if option['type'] in {1, 2}: - command_name += f' {option["name"]}' - command_options = option.get('options') or [] - - command = self.get_command(command_name) - if command is None: - raise errors.CommandNotFound(f'Command "{command_name}" is not found') - # Fetch a valid prefix, so process_commands can function message = _FakeSlashMessage.from_interaction(interaction) prefix = await self.get_prefix(message) @@ -1157,7 +1158,6 @@ class BotBase(GroupMixin): message.content = f'{prefix}{command_name} ' for name, param in command.clean_params.items(): option = next((o for o in command_options if o['name'] == name), None) # type: ignore - if option is None: if param.default is param.empty and not command._is_typing_optional(param.annotation): raise errors.MissingRequiredArgument(param) @@ -1178,7 +1178,7 @@ class BotBase(GroupMixin): quoted = False string = option['value'] for open, close in supported_quotes.items(): - if not (open in string or close in string): + if open not in string and close not in string: message.content += f"{open}{string}{close} " quoted = True break @@ -1195,6 +1195,15 @@ class BotBase(GroupMixin): await self.invoke(ctx) + async def on_message(self, message): + if self.message_commands: + await self.process_commands(message) + + async def on_interaction(self, interaction: discord.Interaction): + if self.slash_commands and interaction.type == discord.InteractionType.application_command: + await self.process_slash_commands(interaction) + + class Bot(BotBase, discord.Client): """Represents a discord bot. -- 2.47.2 From fcb72c122506833980e3115af5d2e3afa5ee2930 Mon Sep 17 00:00:00 2001 From: iDutchy <42503862+iDutchy@users.noreply.github.com> Date: Thu, 2 Sep 2021 02:16:01 +0200 Subject: [PATCH 13/32] Create slash_only.py Basic example for slash commands --- examples/slash_commands/slash_only.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 examples/slash_commands/slash_only.py diff --git a/examples/slash_commands/slash_only.py b/examples/slash_commands/slash_only.py new file mode 100644 index 00000000..22c7fe26 --- /dev/null +++ b/examples/slash_commands/slash_only.py @@ -0,0 +1,26 @@ +from discord.ext.commands import Bot, Cog, Option + +bot = Bot(slash_commands=True, normal_commands=False) # Setting normal_commands to False will make this slash commands only +bot.add_cog(Info(bot)) + +@bot.command(slash=True) +async def hello(ctx): + await ctx.send(f"Hey, {ctx.author.mention}!") + +# And in a cog + +class Info(Cog): + def __init__(self, bot): + self.bot = bot + + @commands.command(slash=True) + async def userinfo(self, ctx, member: discord.Member = Option(description="The user to get info about")): # Note that it must use the description kwarg + """Get info about a member""" + await ctx.send(f"ID: {member.id}") + + @commands.command(slash=True) + async def ban(self, ctx, member: discord.Member = Option(description="The member to ban"), reason: str = Option("No reason", description="Why to ban them")): # The first arg here will be used as default if nothing was provided + await member.ban(reason=reason) + await ctx.send("They have been banned", ephemeral=True) + +bot.run("token") -- 2.47.2 From 15f04aa0d2e71b390248c876a3caab152660c777 Mon Sep 17 00:00:00 2001 From: iDutchy <42503862+iDutchy@users.noreply.github.com> Date: Thu, 2 Sep 2021 02:18:33 +0200 Subject: [PATCH 14/32] Create slash_and_message.py Basic example for mixed commands --- examples/slash_commands/slash_and_message.py | 30 ++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 examples/slash_commands/slash_and_message.py diff --git a/examples/slash_commands/slash_and_message.py b/examples/slash_commands/slash_and_message.py new file mode 100644 index 00000000..87b89fa3 --- /dev/null +++ b/examples/slash_commands/slash_and_message.py @@ -0,0 +1,30 @@ +from discord.ext.commands import Bot, Cog, Option + +bot = Bot(slash_commands=True) # To use both normal and slash commands, you only need to enable slash commands +bot.add_cog(Info(bot)) + +@bot.command(slash=True) +async def hello(ctx): + await ctx.send(f"Hey, {ctx.author.mention}!") + +@bot.command() +async def byw(ctx): + await ctx.send(f"Bye {ctx.author.mention}!") + +# And in a cog + +class Info(Cog): + def __init__(self, bot): + self.bot = bot + + @commands.command(slash=True) + async def userinfo(self, ctx, member: discord.Member = Option(description="The user to get info about")): # Note that it must use the description kwarg + """Get info about a member""" + await ctx.send(f"ID: {member.id}", ephemeral=True) + + @commands.command() + async def ban(self, ctx, member: discord.Member, reason: str = "No reason"): + await member.ban(reason=reason) + await ctx.send("They have been banned") + +bot.run("token") -- 2.47.2 From 355097589ab8082190e5be83ea01f04b72cd1f64 Mon Sep 17 00:00:00 2001 From: Gnome Date: Thu, 2 Sep 2021 12:33:48 +0100 Subject: [PATCH 15/32] Fix slash_command and normal_command bools --- discord/ext/commands/bot.py | 17 ++++------------- discord/ext/commands/core.py | 13 +++++++------ 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 72ece175..e1a553e9 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -181,6 +181,7 @@ class BotBase(GroupMixin): self.owner_id = options.get('owner_id') self.owner_ids = options.get('owner_ids', set()) self.strip_after_prefix = options.get('strip_after_prefix', False) + self.slash_command_guilds: Optional[Iterable[int]] = options.get('slash_command_guilds', None) if self.owner_id and self.owner_ids: raise TypeError('Both owner_id and owner_ids are set.') @@ -190,8 +191,6 @@ class BotBase(GroupMixin): if not (message_commands or slash_commands): raise TypeError("Both message_commands and slash_commands are disabled.") - elif slash_commands: - self.slash_command_guilds: Optional[Iterable[int]] = options.get('slash_command_guilds', None) if help_command is _default: self.help_command = DefaultHelpCommand() @@ -211,7 +210,7 @@ class BotBase(GroupMixin): async def _create_application_commands(self, application_id: int, http: HTTPClient): commands: defaultdict[Optional[int], List[EditApplicationCommand]] = defaultdict(list) for command in self.commands: - if command.hidden: + if command.hidden or (command.slash_command is None and not self.slash_commands): continue payload = command.to_application_command() @@ -1196,12 +1195,10 @@ class BotBase(GroupMixin): async def on_message(self, message): - if self.message_commands: - await self.process_commands(message) + await self.process_commands(message) async def on_interaction(self, interaction: discord.Interaction): - if self.slash_commands and interaction.type == discord.InteractionType.application_command: - await self.process_slash_commands(interaction) + await self.process_slash_commands(interaction) class Bot(BotBase, discord.Client): @@ -1297,9 +1294,6 @@ class Bot(BotBase, discord.Client): """ async def setup(self): - if not self.slash_commands: - return - application = self.application_id or (await self.application_info()).id await self._create_application_commands(application, self.http) @@ -1308,8 +1302,5 @@ class AutoShardedBot(BotBase, discord.AutoShardedClient): :class:`discord.AutoShardedClient` instead. """ async def setup(self): - if not self.slash_commands: - return - application = self.application_id or (await self.application_info()).id await self._create_application_commands(application, self.http) diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index c672d478..ea94a9b5 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -1142,13 +1142,14 @@ class Command(_BaseCommand, Generic[CogT, P, T]): :class:`bool` A boolean indicating if the command can be invoked. """ - if not self.enabled or ( - ctx.interaction is not None - and self.slash_command is False - ) or ( - ctx.interaction is None - and self.message_command is False + ctx.interaction is None and ( + self.message_command is False + or (self.message_command is None and not ctx.bot.message_commands) + ) or ( + self.slash_command is False + or (self.slash_command is None and not ctx.bot.slash_commands) + ) ): raise DisabledCommand(f'{self.name} command is disabled') -- 2.47.2 From fe780a04a230c006136a6a84956e847dca4f630e Mon Sep 17 00:00:00 2001 From: Gnome Date: Thu, 2 Sep 2021 13:07:07 +0100 Subject: [PATCH 16/32] Add some basic error handling for registration --- discord/ext/commands/bot.py | 2 -- discord/ext/commands/core.py | 10 ++++++---- discord/ext/commands/errors.py | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index e1a553e9..885fa3b3 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -1130,8 +1130,6 @@ class BotBase(GroupMixin): command = self.get_command(command_name) if command is None: raise errors.CommandNotFound(f'Command "{command_name}" is not found') - elif not command.slash_command: - return # Ensure the interaction channel is usable channel = interaction.channel diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index ea94a9b5..b51d67d2 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -1211,10 +1211,12 @@ class Command(_BaseCommand, Generic[CogT, P, T]): annotation, origin = annotation.__args__[0], None if origin is None: - option["type"] = next( - (num for t, num in application_option_type_lookup.items() - if issubclass(annotation, t)), 3 - ) + generator = (num for t, num in application_option_type_lookup.items() if issubclass(annotation, t)) + try: + option["type"] = next(generator, 3) + except Exception as err: + raise ApplicationCommandRegistrationError(self) + elif origin is Literal and len(origin.__args__) <= 25: # type: ignore option["choices"] = [{ "name": literal_value, diff --git a/discord/ext/commands/errors.py b/discord/ext/commands/errors.py index 93834385..01dbaee2 100644 --- a/discord/ext/commands/errors.py +++ b/discord/ext/commands/errors.py @@ -33,6 +33,7 @@ if TYPE_CHECKING: from .converter import Converter from .context import Context + from .core import Command from .cooldowns import Cooldown, BucketType from .flags import Flag from discord.abc import GuildChannel @@ -93,6 +94,7 @@ __all__ = ( 'ExtensionFailed', 'ExtensionNotFound', 'CommandRegistrationError', + 'ApplicationCommandRegistrationError', 'FlagError', 'BadFlagArgument', 'MissingFlagArgument', @@ -915,6 +917,23 @@ class CommandRegistrationError(ClientException): type_ = 'alias' if alias_conflict else 'command' super().__init__(f'The {type_} {name} is already an existing command or alias.') +class ApplicationCommandRegistrationError(ClientException): + """An exception raised when a command cannot be converted to an + application command. + + This inherits from :exc:`discord.ClientException` + + .. versionadded:: 2.0 + + Attributes + ---------- + command: :class:`Command` + The command that failed to be converted. + """ + def __init__(self, command: Command) -> None: + self.command = command + super().__init__(f"{command.qualified_name} failed to converted to an application command.") + class FlagError(BadArgument): """The base exception type for all flag parsing related errors. -- 2.47.2 From 5eeefb8af6f485c7b33056e58e9fdf87b951ea70 Mon Sep 17 00:00:00 2001 From: Gnome Date: Thu, 2 Sep 2021 14:31:19 +0100 Subject: [PATCH 17/32] Fixed converter upload errors --- discord/ext/commands/bot.py | 8 ++++++-- discord/ext/commands/core.py | 22 ++++++++++++++++------ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 885fa3b3..bb8234cb 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -46,7 +46,7 @@ from discord.types.interactions import ( _ApplicationCommandInteractionDataOptionString ) -from .core import Command, GroupMixin +from .core import GroupMixin from .converter import Greedy from .view import StringView, supported_quotes from .context import Context @@ -213,7 +213,11 @@ class BotBase(GroupMixin): if command.hidden or (command.slash_command is None and not self.slash_commands): continue - payload = command.to_application_command() + try: + payload = command.to_application_command() + except Exception: + raise errors.ApplicationCommandRegistrationError(command) + if payload is None: continue diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index b51d67d2..0ec54418 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -54,7 +54,7 @@ import discord from .errors import * from .cooldowns import Cooldown, BucketType, CooldownMapping, MaxConcurrency, DynamicCooldownMapping -from .converter import run_converters, get_converter, Greedy, Option +from .converter import CONVERTER_MAPPING, Converter, run_converters, get_converter, Greedy, Option from ._types import _BaseCommand from .cog import Cog from .context import Context @@ -112,6 +112,8 @@ ContextT = TypeVar('ContextT', bound='Context') GroupT = TypeVar('GroupT', bound='Group') HookT = TypeVar('HookT', bound='Hook') ErrorT = TypeVar('ErrorT', bound='Error') + +REVERSED_CONVERTER_MAPPING = {v: k for k, v in CONVERTER_MAPPING.items()} application_option_type_lookup = { str: 3, bool: 5, @@ -1211,11 +1213,19 @@ class Command(_BaseCommand, Generic[CogT, P, T]): annotation, origin = annotation.__args__[0], None if origin is None: - generator = (num for t, num in application_option_type_lookup.items() if issubclass(annotation, t)) - try: - option["type"] = next(generator, 3) - except Exception as err: - raise ApplicationCommandRegistrationError(self) + if not inspect.isclass(annotation): + annotation = type(annotation) + + if issubclass(annotation, Converter): + # If this is a converter, we want to check if it is a native + # one, in which we can get the original type, eg, (MemberConverter -> Member) + annotation = REVERSED_CONVERTER_MAPPING.get(annotation, annotation) + + option["type"] = 3 + for python_type, discord_type in application_option_type_lookup.items(): + if issubclass(annotation, python_type): + option["type"] = discord_type + break elif origin is Literal and len(origin.__args__) <= 25: # type: ignore option["choices"] = [{ -- 2.47.2 From ce16dc97ed04337b4c0a62f08ee520e8e1bdc441 Mon Sep 17 00:00:00 2001 From: Gnome Date: Thu, 2 Sep 2021 16:10:35 +0100 Subject: [PATCH 18/32] Fix some logic and make an actual example --- discord/ext/commands/core.py | 22 +++++++----- examples/slash_commands.py | 35 ++++++++++++++++++++ examples/slash_commands/slash_and_message.py | 30 ----------------- examples/slash_commands/slash_only.py | 26 --------------- 4 files changed, 48 insertions(+), 65 deletions(-) create mode 100644 examples/slash_commands.py delete mode 100644 examples/slash_commands/slash_and_message.py delete mode 100644 examples/slash_commands/slash_only.py diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index 0ec54418..b5eef841 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -1144,17 +1144,21 @@ class Command(_BaseCommand, Generic[CogT, P, T]): :class:`bool` A boolean indicating if the command can be invoked. """ - if not self.enabled or ( - ctx.interaction is None and ( - self.message_command is False - or (self.message_command is None and not ctx.bot.message_commands) - ) or ( - self.slash_command is False - or (self.slash_command is None and not ctx.bot.slash_commands) - ) - ): + if not self.enabled: raise DisabledCommand(f'{self.name} command is disabled') + if ctx.interaction is None and ( + self.message_command is False + or (self.message_command is None and not ctx.bot.message_commands) + ): + raise DisabledCommand(f'{self.name} command cannot be run as a message command') + + if ctx.interaction is not None and ( + self.slash_command is False + or (self.slash_command is None and not ctx.bot.slash_commands) + ): + raise DisabledCommand(f'{self.name} command cannot be run as a slash command') + original = ctx.command ctx.command = self diff --git a/examples/slash_commands.py b/examples/slash_commands.py new file mode 100644 index 00000000..85a589df --- /dev/null +++ b/examples/slash_commands.py @@ -0,0 +1,35 @@ +import discord +from discord.ext import commands + +# Set slash commands=True when constructing your bot to enable all slash commands +# if your bot is only for a couple of servers, you can use the parameter +# `slash_command_guilds=[list, of, guild, ids]` to specify this, +# then the commands will be much faster to upload. +bot = commands.Bot("!", intents=discord.Intents(guilds=True, messages=True), slash_commands=True, slash_command_guilds=[514232441498763279]) + +@bot.event +async def on_ready(): + print(f'Logged in as {bot.user} (ID: {bot.user.id})') + print('------') + + +@bot.command() +# You can use commands.Option to define descriptions for your options, and converters will still work fine. +async def ping(ctx: commands.Context, emoji: bool = commands.Option(description="whether to use an emoji when responding")): + # This command can be used with slash commands or message commands + if emoji: + await ctx.send("\U0001f3d3") + else: + await ctx.send("Pong!") + +@bot.command(message_command=False) +async def only_slash(ctx: commands.Context): + # This command can only be used with slash commands + await ctx.send("Hello from slash commands!") + +@bot.command(slash_command=False) +async def only_message(ctx: commands.Context): + # This command can only be used with a message + await ctx.send("Hello from message commands!") + +bot.run("NjA1NDM2ODYyNjAxMTAxMzIz.XT8fBA.MCwQg6Bz_TuQ4oktliXHdGczCDY") diff --git a/examples/slash_commands/slash_and_message.py b/examples/slash_commands/slash_and_message.py deleted file mode 100644 index 87b89fa3..00000000 --- a/examples/slash_commands/slash_and_message.py +++ /dev/null @@ -1,30 +0,0 @@ -from discord.ext.commands import Bot, Cog, Option - -bot = Bot(slash_commands=True) # To use both normal and slash commands, you only need to enable slash commands -bot.add_cog(Info(bot)) - -@bot.command(slash=True) -async def hello(ctx): - await ctx.send(f"Hey, {ctx.author.mention}!") - -@bot.command() -async def byw(ctx): - await ctx.send(f"Bye {ctx.author.mention}!") - -# And in a cog - -class Info(Cog): - def __init__(self, bot): - self.bot = bot - - @commands.command(slash=True) - async def userinfo(self, ctx, member: discord.Member = Option(description="The user to get info about")): # Note that it must use the description kwarg - """Get info about a member""" - await ctx.send(f"ID: {member.id}", ephemeral=True) - - @commands.command() - async def ban(self, ctx, member: discord.Member, reason: str = "No reason"): - await member.ban(reason=reason) - await ctx.send("They have been banned") - -bot.run("token") diff --git a/examples/slash_commands/slash_only.py b/examples/slash_commands/slash_only.py deleted file mode 100644 index 22c7fe26..00000000 --- a/examples/slash_commands/slash_only.py +++ /dev/null @@ -1,26 +0,0 @@ -from discord.ext.commands import Bot, Cog, Option - -bot = Bot(slash_commands=True, normal_commands=False) # Setting normal_commands to False will make this slash commands only -bot.add_cog(Info(bot)) - -@bot.command(slash=True) -async def hello(ctx): - await ctx.send(f"Hey, {ctx.author.mention}!") - -# And in a cog - -class Info(Cog): - def __init__(self, bot): - self.bot = bot - - @commands.command(slash=True) - async def userinfo(self, ctx, member: discord.Member = Option(description="The user to get info about")): # Note that it must use the description kwarg - """Get info about a member""" - await ctx.send(f"ID: {member.id}") - - @commands.command(slash=True) - async def ban(self, ctx, member: discord.Member = Option(description="The member to ban"), reason: str = Option("No reason", description="Why to ban them")): # The first arg here will be used as default if nothing was provided - await member.ban(reason=reason) - await ctx.send("They have been banned", ephemeral=True) - -bot.run("token") -- 2.47.2 From 7bf90f9e27e94e522bf0146ea72777530c4addb7 Mon Sep 17 00:00:00 2001 From: Gnome Date: Thu, 2 Sep 2021 16:12:08 +0100 Subject: [PATCH 19/32] Thanks Safety Jim --- examples/slash_commands.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/slash_commands.py b/examples/slash_commands.py index 85a589df..f32556a4 100644 --- a/examples/slash_commands.py +++ b/examples/slash_commands.py @@ -5,7 +5,7 @@ from discord.ext import commands # if your bot is only for a couple of servers, you can use the parameter # `slash_command_guilds=[list, of, guild, ids]` to specify this, # then the commands will be much faster to upload. -bot = commands.Bot("!", intents=discord.Intents(guilds=True, messages=True), slash_commands=True, slash_command_guilds=[514232441498763279]) +bot = commands.Bot("!", intents=discord.Intents(guilds=True, messages=True), slash_commands=True) @bot.event async def on_ready(): @@ -32,4 +32,4 @@ async def only_message(ctx: commands.Context): # This command can only be used with a message await ctx.send("Hello from message commands!") -bot.run("NjA1NDM2ODYyNjAxMTAxMzIz.XT8fBA.MCwQg6Bz_TuQ4oktliXHdGczCDY") +bot.run("token") -- 2.47.2 From 8a779ef595a8c167309110eeb4de28b8e0932dc6 Mon Sep 17 00:00:00 2001 From: Gnome Date: Thu, 2 Sep 2021 21:24:37 +0100 Subject: [PATCH 20/32] docstrings, *args, and error changes --- discord/client.py | 4 +++- discord/ext/commands/bot.py | 19 ++++++++++++++++++- discord/ext/commands/converter.py | 2 +- discord/ext/commands/core.py | 6 +++--- discord/ext/commands/errors.py | 4 ++-- 5 files changed, 27 insertions(+), 8 deletions(-) diff --git a/discord/client.py b/discord/client.py index 1d3cd5ec..f9a1d9e6 100644 --- a/discord/client.py +++ b/discord/client.py @@ -615,6 +615,8 @@ class Client: To perform asynchronous setup after the bot is logged in but before it has connected to the Websocket, overwrite this coroutine. + + .. versionadded:: 2.0 """ pass @@ -706,7 +708,7 @@ class Client: """:class:`.Status`: The status being used upon logging on to Discord. - .. versionadded: 2.0 + .. versionadded:: 2.0 """ if self._connection._status in set(state.value for state in Status): return Status(self._connection._status) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index bb8234cb..8d19592c 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -1128,6 +1128,24 @@ class BotBase(GroupMixin): async def process_slash_commands(self, interaction: discord.Interaction): + """|coro| + + This function processes a slash command interaction into a usable + message and calls :meth:`.process_commands` based on it. Without this + coroutine slash commands will not be triggered. + + By default, this coroutine is called inside the :func:`.on_interaction` + event. If you choose to override the :func:`.on_interaction` event, + then you should invoke this coroutine as well. + + .. versionadded:: 2.0 + + Parameters + ----------- + interaction: :class:`discord.Interaction` + The interaction to process slash commands for. + + """ interaction.data = cast(ApplicationCommandInteractionData, interaction.data) command_name, command_options = _unwrap_slash_groups(interaction.data) @@ -1164,7 +1182,6 @@ class BotBase(GroupMixin): raise errors.MissingRequiredArgument(param) elif ( option["type"] == 3 - and " " in option["value"] # type: ignore and param.kind != param.KEYWORD_ONLY and not isinstance(param.annotation, Greedy) ): diff --git a/discord/ext/commands/converter.py b/discord/ext/commands/converter.py index 7d46b693..0545ead7 100644 --- a/discord/ext/commands/converter.py +++ b/discord/ext/commands/converter.py @@ -586,7 +586,7 @@ class ThreadConverter(IDConverter[discord.Thread]): 2. Lookup by mention. 3. Lookup by name. - .. versionadded: 2.0 + .. versionadded:: 2.0 """ async def convert(self, ctx: Context, argument: str) -> discord.Thread: diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index b5eef841..6bca3f31 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -1187,7 +1187,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): if self.slash_command is False: return elif nested == 3: - raise ValueError(f"{self.qualified_name} is too deeply nested!") + raise ApplicationCommandRegistrationError(self, f"{self.qualified_name} is too deeply nested!") payload = { "name": self.name, @@ -1208,7 +1208,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): option: Dict[str, Any] = { "name": name, "description": self.option_descriptions[name], - "required": param.default is param.empty and not self._is_typing_optional(annotation), + "required": (param.default is param.empty and not self._is_typing_optional(annotation)) or param.kind == param.VAR_POSITIONAL, } annotation = cast(Any, annotation) @@ -1616,7 +1616,7 @@ class Group(GroupMixin[CogT], Command[CogT, P, T]): if self.slash_command is False: return elif nested == 2: - raise ValueError(f"{self.qualified_name} is too deeply nested for slash commands!") + raise ApplicationCommandRegistrationError(self, f"{self.qualified_name} is too deeply nested!") return { # type: ignore "name": self.name, diff --git a/discord/ext/commands/errors.py b/discord/ext/commands/errors.py index 01dbaee2..a150b215 100644 --- a/discord/ext/commands/errors.py +++ b/discord/ext/commands/errors.py @@ -930,9 +930,9 @@ class ApplicationCommandRegistrationError(ClientException): command: :class:`Command` The command that failed to be converted. """ - def __init__(self, command: Command) -> None: + def __init__(self, command: Command, msg: str = None) -> None: self.command = command - super().__init__(f"{command.qualified_name} failed to converted to an application command.") + super().__init__(msg or f"{command.qualified_name} failed to converted to an application command.") class FlagError(BadArgument): """The base exception type for all flag parsing related errors. -- 2.47.2 From 623fcc0ac059bcf51daeb78a7c24e4a727a3c617 Mon Sep 17 00:00:00 2001 From: Gnome Date: Fri, 3 Sep 2021 20:18:15 +0100 Subject: [PATCH 21/32] Add proper literal support --- discord/ext/commands/core.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index 6bca3f31..b23f8d16 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -1231,14 +1231,19 @@ class Command(_BaseCommand, Generic[CogT, P, T]): option["type"] = discord_type break - elif origin is Literal and len(origin.__args__) <= 25: # type: ignore - option["choices"] = [{ - "name": literal_value, - "value": literal_value - } for literal_value in origin.__args__] # type: ignore - else: - option["type"] = 3 # STRING + elif origin is Literal: + literal_values = annotation.__args__ + python_type = type(literal_values[0]) + if (all(type(value) == python_type for value in literal_values) + and python_type in application_option_type_lookup.keys()): + option["type"] = application_option_type_lookup[python_type] + option["choices"] = [{ + "name": literal_value, + "value": literal_value + } for literal_value in annotation.__args__] + + option.setdefault("type", 3) # STRING payload["options"].append(option) # Now we have all options, make sure required is before optional. -- 2.47.2 From bad92723fc9be2d547806f75ef9ae22740a26e01 Mon Sep 17 00:00:00 2001 From: Gnome Date: Fri, 3 Sep 2021 20:48:43 +0100 Subject: [PATCH 22/32] Add basic documentation on slash commands --- docs/ext/commands/commands.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/ext/commands/commands.rst b/docs/ext/commands/commands.rst index f205be2a..829fa24a 100644 --- a/docs/ext/commands/commands.rst +++ b/docs/ext/commands/commands.rst @@ -61,6 +61,13 @@ the name to something other than the function would be as simple as doing this: async def _list(ctx, arg): pass +Slash Commands +-------------- +Slash Commands can be enabled in the :class:`.Bot` constructor or :class:`.Command` constructor, using +``slash_commands=True`` or ``slash_command=True`` respectfully. All features of the commands extension +should work with these options enabled, however many will not have direct discord counterparts and therefore +will be subsituted for supported versions when uploaded to discord. + Parameters ------------ @@ -179,6 +186,11 @@ know how the command was executed. It contains a lot of useful information: The context implements the :class:`abc.Messageable` interface, so anything you can do on a :class:`abc.Messageable` you can do on the :class:`~ext.commands.Context`. +.. warning:: + :attr:`.Context.message` will be fake if in a slash command, it is not recommended to use this attribute + if :attr:`.Context.interaction` is not None. Currently this will emit a warning and fail silently, however + this behaviour may be changed in the future. + Converters ------------ -- 2.47.2 From 6cb0737dfefbd37eaa69315b87863b7f6746dbc0 Mon Sep 17 00:00:00 2001 From: Gnome Date: Sat, 4 Sep 2021 10:59:42 +0100 Subject: [PATCH 23/32] Fix non-slash command interactions --- discord/ext/commands/bot.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 8d19592c..79e15f0c 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -28,7 +28,6 @@ from __future__ import annotations import asyncio import collections import collections.abc -from discord.http import HTTPClient import inspect import importlib.util @@ -36,6 +35,7 @@ import sys import traceback import types from collections import defaultdict +from discord.http import HTTPClient from typing import Any, Callable, Iterable, Tuple, cast, Mapping, List, Dict, TYPE_CHECKING, Optional, TypeVar, Type, Union import discord @@ -1146,6 +1146,9 @@ class BotBase(GroupMixin): The interaction to process slash commands for. """ + if interaction.type != discord.InteractionType.application_command: + return + interaction.data = cast(ApplicationCommandInteractionData, interaction.data) command_name, command_options = _unwrap_slash_groups(interaction.data) -- 2.47.2 From 2a1dc8a069c6e44f6c7d79565aa7c822f7e7b368 Mon Sep 17 00:00:00 2001 From: Gnome Date: Sat, 4 Sep 2021 20:57:29 +0100 Subject: [PATCH 24/32] Fix ctx.reply in slash command context --- discord/ext/commands/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index 2e05208c..800c5d7a 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -475,4 +475,4 @@ class Context(discord.abc.Messageable, Generic[BotT]): @discord.utils.copy_doc(Message.reply) async def reply(self, content: Optional[str] = None, **kwargs: Any) -> Message: - return await self.message.reply(content, **kwargs) + return await self.send(content, reference=self.message, **kwargs) # type: ignore -- 2.47.2 From e9b95eaff7b5c54e40d1bafd23fd662021164887 Mon Sep 17 00:00:00 2001 From: Gnome Date: Sat, 4 Sep 2021 21:26:32 +0100 Subject: [PATCH 25/32] Fix typing on Context.reply --- discord/ext/commands/context.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index 800c5d7a..8cc2afb6 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -473,6 +473,11 @@ class Context(discord.abc.Messageable, Generic[BotT]): return await send(content, ephemeral=ephemeral, **kwargs) # type: ignore + @overload + async def reply(self, content: Optional[str] = None, return_message: Literal[False] = False, **kwargs: Any) -> Optional[Union[Message, WebhookMessage]]: ... + @overload + async def reply(self, content: Optional[str] = None, return_message: Literal[True] = True, **kwargs: Any) -> Union[Message, WebhookMessage]: ... + @discord.utils.copy_doc(Message.reply) - async def reply(self, content: Optional[str] = None, **kwargs: Any) -> Message: - return await self.send(content, reference=self.message, **kwargs) # type: ignore + async def reply(self, content: Optional[str] = None, return_message: bool = True, **kwargs: Any) -> Optional[Union[Message, WebhookMessage]]: + return await self.send(content, return_message=return_message, reference=self.message, **kwargs) # type: ignore -- 2.47.2 From 575a92cd3ac2fd716b3da0d635ce93178392ef19 Mon Sep 17 00:00:00 2001 From: Gnome Date: Sun, 5 Sep 2021 12:39:53 +0100 Subject: [PATCH 26/32] Fix multiple optional argument sorting --- discord/ext/commands/bot.py | 37 +++++++++++++++++---------------- discord/ext/commands/context.py | 1 + discord/ext/commands/core.py | 4 ++++ 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 79e15f0c..24502f84 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -148,6 +148,19 @@ def _unwrap_slash_groups(data: ApplicationCommandInteractionData) -> Tuple[str, return command_name, command_options +def _quote_string_safe(string: str) -> str: + # we need to quote this string otherwise we may spill into + # other parameters and cause all kinds of trouble, as many + # quotes are supported and some may be in the option, we + # loop through all supported quotes and if neither open or + # close are in the string, we add them + for open, close in supported_quotes.items(): + if open not in string and close not in string: + return f"{open}{string}{close}" + + # all supported quotes are in the message and we cannot add any + # safely, very unlikely but still got to be covered + raise errors.UnexpectedQuoteError(string) class _DefaultRepr: def __repr__(self): @@ -1177,12 +1190,15 @@ class BotBase(GroupMixin): prefix = prefix[0] # Add arguments to fake message content, in the right order + ignore_params: List[inspect.Parameter] = [] message.content = f'{prefix}{command_name} ' for name, param in command.clean_params.items(): option = next((o for o in command_options if o['name'] == name), None) # type: ignore if option is None: if param.default is param.empty and not command._is_typing_optional(param.annotation): raise errors.MissingRequiredArgument(param) + else: + ignore_params.append(param) elif ( option["type"] == 3 and param.kind != param.KEYWORD_ONLY @@ -1190,28 +1206,13 @@ class BotBase(GroupMixin): ): # String with space in without "consume rest" option = cast(_ApplicationCommandInteractionDataOptionString, option) - - # we need to quote this string otherwise we may spill into - # other parameters and cause all kinds of trouble, as many - # quotes are supported and some may be in the option, we - # loop through all supported quotes and if neither open or - # close are in the string, we add them - quoted = False - string = option['value'] - for open, close in supported_quotes.items(): - if open not in string and close not in string: - message.content += f"{open}{string}{close} " - quoted = True - break - - # all supported quotes are in the message and we cannot add any - # safely, very unlikely but still got to be covered - if not quoted: - raise errors.UnexpectedQuoteError(string) + quoted_string = _quote_string_safe(option['value']) + message.content += f'{quoted_string} ' else: message.content += f'{option.get("value", "")} ' ctx = await self.get_context(message) + ctx._ignored_params = ignore_params ctx.interaction = interaction await self.invoke(ctx) diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index 8cc2afb6..73d8d8fb 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -154,6 +154,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): self.subcommand_passed: Optional[str] = subcommand_passed self.command_failed: bool = command_failed self.current_parameter: Optional[inspect.Parameter] = current_parameter + self._ignored_params: List[inspect.Parameter] = [] self._state: ConnectionState = self.message._state async def invoke(self, command: Command[CogT, P, T], /, *args: P.args, **kwargs: P.kwargs) -> T: diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index b23f8d16..30d11883 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -577,6 +577,10 @@ class Command(_BaseCommand, Generic[CogT, P, T]): ctx.bot.dispatch('command_error', ctx, error) async def transform(self, ctx: Context, param: inspect.Parameter) -> Any: + if param in ctx._ignored_params: + # in a slash command, we need a way to mark a param as default so ctx._ignored_params is used + return param.default if param.default is not param.empty else None + required = param.default is param.empty converter = get_converter(param) consume_rest_is_special = param.kind == param.KEYWORD_ONLY and not self.rest_is_raw -- 2.47.2 From 1391305e5a1cb90cd0a96455336bd4b28ce6c5bc Mon Sep 17 00:00:00 2001 From: Gnome Date: Sun, 5 Sep 2021 15:30:53 +0100 Subject: [PATCH 27/32] Update ctx.message docs to mention error instead of warning --- docs/ext/commands/commands.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/ext/commands/commands.rst b/docs/ext/commands/commands.rst index 829fa24a..0f73f050 100644 --- a/docs/ext/commands/commands.rst +++ b/docs/ext/commands/commands.rst @@ -187,9 +187,9 @@ The context implements the :class:`abc.Messageable` interface, so anything you c can do on the :class:`~ext.commands.Context`. .. warning:: - :attr:`.Context.message` will be fake if in a slash command, it is not recommended to use this attribute - if :attr:`.Context.interaction` is not None. Currently this will emit a warning and fail silently, however - this behaviour may be changed in the future. + :attr:`.Context.message` will be fake if in a slash command, it is not + recommended to access if :attr:`.Context.interaction` is not None as most + methods will error due to the message not actually existing. Converters ------------ -- 2.47.2 From 17096629cdc7d1f181890cbf7bd75871984e6417 Mon Sep 17 00:00:00 2001 From: Gnome Date: Mon, 6 Sep 2021 20:05:06 +0100 Subject: [PATCH 28/32] Move slash command creation to BotBase --- discord/ext/commands/bot.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 56e0c4f2..47c58c7d 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -248,7 +248,10 @@ class BotBase(GroupMixin): for event in self.extra_events.get(ev, []): self._schedule_event(event, ev, *args, **kwargs) # type: ignore - async def _create_application_commands(self, application_id: int, http: HTTPClient): + async def setup(self): + await self.create_slash_commands() + + async def create_slash_commands(self): commands: defaultdict[Optional[int], List[EditApplicationCommand]] = defaultdict(list) for command in self.commands: if command.hidden or (command.slash_command is None and not self.slash_commands): @@ -269,7 +272,9 @@ class BotBase(GroupMixin): for guild in guilds: commands[guild].append(payload) + http: HTTPClient = self.http # type: ignore global_commands = commands.pop(None, None) + application_id = self.application_id or (await self.application_info()).id # type: ignore if global_commands is not None: if self.slash_command_guilds is None: await http.bulk_upsert_global_commands( @@ -1385,9 +1390,7 @@ class Bot(BotBase, discord.Client): """ - async def setup(self): - application = self.application_id or (await self.application_info()).id - await self._create_application_commands(application, self.http) + pass class AutoShardedBot(BotBase, discord.AutoShardedClient): @@ -1395,6 +1398,4 @@ class AutoShardedBot(BotBase, discord.AutoShardedClient): :class:`discord.AutoShardedClient` instead. """ - async def setup(self): - application = self.application_id or (await self.application_info()).id - await self._create_application_commands(application, self.http) + pass -- 2.47.2 From f82fa5315d0ff4e3ea8de3d33481d47b7557336d Mon Sep 17 00:00:00 2001 From: Lint Action Date: Mon, 6 Sep 2021 19:05:39 +0000 Subject: [PATCH 29/32] Fix code style issues with Black --- discord/ext/commands/bot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 47c58c7d..1ff160c5 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -272,9 +272,9 @@ class BotBase(GroupMixin): for guild in guilds: commands[guild].append(payload) - http: HTTPClient = self.http # type: ignore + http: HTTPClient = self.http # type: ignore global_commands = commands.pop(None, None) - application_id = self.application_id or (await self.application_info()).id # type: ignore + application_id = self.application_id or (await self.application_info()).id # type: ignore if global_commands is not None: if self.slash_command_guilds is None: await http.bulk_upsert_global_commands( -- 2.47.2 From 2f3d59e625dfd04d4d4dd9e0d7e2dc000df58170 Mon Sep 17 00:00:00 2001 From: Gnome Date: Thu, 9 Sep 2021 20:49:03 +0100 Subject: [PATCH 30/32] Rearrange some stuff and add flag support --- discord/ext/commands/bot.py | 23 +++++-- discord/ext/commands/core.py | 118 ++++++++++++++++++++--------------- 2 files changed, 87 insertions(+), 54 deletions(-) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 47c58c7d..81ca0b7d 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -64,6 +64,7 @@ from .core import GroupMixin from .converter import Greedy from .view import StringView, supported_quotes from .context import Context +from .flags import FlagConverter from . import errors from .help import HelpCommand, DefaultHelpCommand from .cog import Cog @@ -272,9 +273,9 @@ class BotBase(GroupMixin): for guild in guilds: commands[guild].append(payload) - http: HTTPClient = self.http # type: ignore + http: HTTPClient = self.http # type: ignore global_commands = commands.pop(None, None) - application_id = self.application_id or (await self.application_info()).id # type: ignore + application_id = self.application_id or (await self.application_info()).id # type: ignore if global_commands is not None: if self.slash_command_guilds is None: await http.bulk_upsert_global_commands( @@ -1271,7 +1272,20 @@ class BotBase(GroupMixin): ignore_params: List[inspect.Parameter] = [] message.content = f"{prefix}{command_name} " for name, param in command.clean_params.items(): - option = next((o for o in command_options if o["name"] == name), None) # type: ignore + if inspect.isclass(param.annotation) and issubclass(param.annotation, FlagConverter): + for name, flag in param.annotation.get_flags().items(): + option = next((o for o in command_options if o["name"] == name), None) + + if option is None: + if flag.required: + raise errors.MissingRequiredFlag(flag) + else: + prefix = param.annotation.__commands_flag_prefix__ + delimiter = param.annotation.__commands_flag_delimiter__ + message.content += f"{prefix}{name} {option['value']}{delimiter}" # type: ignore + continue + + option = next((o for o in command_options if o["name"] == name), None) if option is None: if param.default is param.empty and not command._is_typing_optional(param.annotation): raise errors.MissingRequiredArgument(param) @@ -1280,8 +1294,7 @@ class BotBase(GroupMixin): elif option["type"] == 3 and param.kind != param.KEYWORD_ONLY and not isinstance(param.annotation, Greedy): # String with space in without "consume rest" option = cast(_ApplicationCommandInteractionDataOptionString, option) - quoted_string = _quote_string_safe(option["value"]) - message.content += f"{quoted_string} " + message.content += f"{_quote_string_safe(option['value'])} " else: message.content += f'{option.get("value", "")} ' diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index 4c6d2c5a..ed1b8bfe 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -58,13 +58,14 @@ from .converter import CONVERTER_MAPPING, Converter, run_converters, get_convert from ._types import _BaseCommand from .cog import Cog from .context import Context +from .flags import FlagConverter if TYPE_CHECKING: from typing_extensions import Concatenate, ParamSpec, TypeGuard from discord.message import Message - from discord.types.interactions import EditApplicationCommand + from discord.types.interactions import EditApplicationCommand, ApplicationCommandInteractionDataOption from ._types import ( Coro, @@ -1202,6 +1203,65 @@ class Command(_BaseCommand, Generic[CogT, P, T]): finally: ctx.command = original + def _param_to_options( + self, name: str, annotation: Any, required: bool, varadic: bool + ) -> List[Optional[ApplicationCommandInteractionDataOption]]: + + origin = getattr(annotation, "__origin__", None) + if inspect.isclass(annotation) and issubclass(annotation, FlagConverter): + return [ + param + for name, flag in annotation.get_flags().items() + for param in self._param_to_options( + name, flag.annotation, required=flag.required, varadic=flag.annotation is tuple + ) + ] + + if varadic: + annotation = str + origin = None + + if not required and origin is not None and len(annotation.__args__) == 2: + # Unpack Optional[T] (Union[T, None]) into just T + annotation, origin = annotation.__args__[0], None + + option: Dict[str, Any] = { + "name": name, + "required": required, + "description": self.option_descriptions[name], + } + + if origin is None: + if not inspect.isclass(annotation): + annotation = type(annotation) + + if issubclass(annotation, Converter): + # If this is a converter, we want to check if it is a native + # one, in which we can get the original type, eg, (MemberConverter -> Member) + annotation = REVERSED_CONVERTER_MAPPING.get(annotation, annotation) + + option["type"] = 3 + for python_type, discord_type in application_option_type_lookup.items(): + if issubclass(annotation, python_type): + option["type"] = discord_type + break + + elif origin is Literal: + literal_values = annotation.__args__ + python_type = type(literal_values[0]) + if ( + all(type(value) == python_type for value in literal_values) + and python_type in application_option_type_lookup.keys() + ): + + option["type"] = application_option_type_lookup[python_type] + option["choices"] = [ + {"name": literal_value, "value": literal_value} for literal_value in annotation.__args__ + ] + + option.setdefault("type", 3) # STRING + return [option] # type: ignore + def to_application_command(self, nested: int = 0) -> Optional[EditApplicationCommand]: if self.slash_command is False: return @@ -1213,55 +1273,15 @@ class Command(_BaseCommand, Generic[CogT, P, T]): payload["type"] = 1 for name, param in self.clean_params.items(): - annotation: Type[Any] = param.annotation if param.annotation is not param.empty else str - origin = getattr(param.annotation, "__origin__", None) - - if origin is None and isinstance(annotation, Greedy): - annotation = annotation.converter - origin = Greedy - - option: Dict[str, Any] = { - "name": name, - "description": self.option_descriptions[name], - "required": (param.default is param.empty and not self._is_typing_optional(annotation)) + options = self._param_to_options( + name, + param.annotation if param.annotation is not param.empty else str, + varadic=param.kind == param.KEYWORD_ONLY or isinstance(param.annotation, Greedy), + required=(param.default is param.empty and not self._is_typing_optional(param.annotation)) or param.kind == param.VAR_POSITIONAL, - } - - annotation = cast(Any, annotation) - if not option["required"] and origin is not None and len(annotation.__args__) == 2: - # Unpack Optional[T] (Union[T, None]) into just T - annotation, origin = annotation.__args__[0], None - - if origin is None: - if not inspect.isclass(annotation): - annotation = type(annotation) - - if issubclass(annotation, Converter): - # If this is a converter, we want to check if it is a native - # one, in which we can get the original type, eg, (MemberConverter -> Member) - annotation = REVERSED_CONVERTER_MAPPING.get(annotation, annotation) - - option["type"] = 3 - for python_type, discord_type in application_option_type_lookup.items(): - if issubclass(annotation, python_type): - option["type"] = discord_type - break - - elif origin is Literal: - literal_values = annotation.__args__ - python_type = type(literal_values[0]) - if ( - all(type(value) == python_type for value in literal_values) - and python_type in application_option_type_lookup.keys() - ): - - option["type"] = application_option_type_lookup[python_type] - option["choices"] = [ - {"name": literal_value, "value": literal_value} for literal_value in annotation.__args__ - ] - - option.setdefault("type", 3) # STRING - payload["options"].append(option) + ) + if options is not None: + payload["options"].extend(option for option in options if option is not None) # Now we have all options, make sure required is before optional. payload["options"] = sorted(payload["options"], key=itemgetter("required"), reverse=True) -- 2.47.2 From cbf511879055d606d7954b966e75db523de4ed4e Mon Sep 17 00:00:00 2001 From: Gnome Date: Sun, 12 Sep 2021 11:37:24 +0100 Subject: [PATCH 31/32] Change some errors and fix interaction.channel fixing --- discord/ext/commands/bot.py | 15 +++++++-------- discord/ext/commands/core.py | 4 +++- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 81ca0b7d..8973e8ec 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -99,11 +99,13 @@ class _FakeSlashMessage(discord.PartialMessage): tts = False @classmethod - def from_interaction(cls, interaction: discord.Interaction) -> discord.Message: - self: discord.Message = cls(channel=interaction.channel, id=interaction.id) # type: ignore - + def from_interaction( + cls, interaction: discord.Interaction, channel: Union[discord.TextChannel, discord.DMChannel, discord.Thread] + ): + self = cls(channel=channel, id=interaction.id) assert interaction.user is not None self.author = interaction.user + return self @@ -233,7 +235,7 @@ class BotBase(GroupMixin): raise TypeError(f"owner_ids must be a collection not {self.owner_ids.__class__!r}") if not (message_commands or slash_commands): - raise TypeError("Both message_commands and slash_commands are disabled.") + raise ValueError("Both message_commands and slash_commands are disabled.") if help_command is _default: self.help_command = DefaultHelpCommand() @@ -1259,11 +1261,8 @@ class BotBase(GroupMixin): else: return # cannot do anything without stable channel - interaction.channel = channel # type: ignore - del channel - # Fetch a valid prefix, so process_commands can function - message = _FakeSlashMessage.from_interaction(interaction) + message: discord.Message = _FakeSlashMessage.from_interaction(interaction, channel) # type: ignore prefix = await self.get_prefix(message) if isinstance(prefix, list): prefix = prefix[0] diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index ed1b8bfe..fa1e4212 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -429,7 +429,9 @@ class Command(_BaseCommand, Generic[CogT, P, T]): parent = kwargs.get("parent") self.parent: Optional[GroupMixin] = parent if isinstance(parent, _BaseCommand) else None # type: ignore if self.slash_command_guilds is not None and self.parent is not None: - raise TypeError("Cannot set specific guilds for a subcommand. They are inherited from the top level group.") + raise ValueError( + "Cannot set specific guilds for a subcommand. They are inherited from the top level group." + ) self._before_invoke: Optional[Hook] = None try: -- 2.47.2 From bef83719ea10ac5522517dbd182569d2e4dc6816 Mon Sep 17 00:00:00 2001 From: Gnome Date: Fri, 17 Sep 2021 20:30:49 +0100 Subject: [PATCH 32/32] Fix slash command quoting for *args --- discord/ext/commands/bot.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 8973e8ec..7abd6c8a 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -1290,7 +1290,11 @@ class BotBase(GroupMixin): raise errors.MissingRequiredArgument(param) else: ignore_params.append(param) - elif option["type"] == 3 and param.kind != param.KEYWORD_ONLY and not isinstance(param.annotation, Greedy): + elif ( + option["type"] == 3 + and not isinstance(param.annotation, Greedy) + and param.kind in {param.POSITIONAL_OR_KEYWORD, param.POSITIONAL_ONLY} + ): # String with space in without "consume rest" option = cast(_ApplicationCommandInteractionDataOptionString, option) message.content += f"{_quote_string_safe(option['value'])} " -- 2.47.2