diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index 1f467ab91..ea21fcbb5 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -56,7 +56,7 @@ from .errors import * from .parameters import Parameter, Signature if TYPE_CHECKING: - from typing_extensions import Concatenate, ParamSpec, Self, TypeGuard + from typing_extensions import Concatenate, ParamSpec, Self from discord.message import Message @@ -237,6 +237,27 @@ class _CaseInsensitiveDict(dict): super().__setitem__(k.casefold(), v) +class _AttachmentIterator: + def __init__(self, data: List[discord.Attachment]): + self.data: List[discord.Attachment] = data + self.index: int = 0 + + def __iter__(self) -> Self: + return self + + def __next__(self) -> discord.Attachment: + try: + value = self.data[self.index] + except IndexError: + raise StopIteration + else: + self.index += 1 + return value + + def is_empty(self) -> bool: + return self.index >= len(self.data) + + class Command(_BaseCommand, Generic[CogT, P, T]): r"""A class that implements the protocol for a bot text command. @@ -592,7 +613,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): finally: ctx.bot.dispatch('command_error', ctx, error) - async def transform(self, ctx: Context[BotT], param: Parameter, /) -> Any: + async def transform(self, ctx: Context[BotT], param: Parameter, attachments: _AttachmentIterator, /) -> Any: converter = param.converter consume_rest_is_special = param.kind == param.KEYWORD_ONLY and not self.rest_is_raw view = ctx.view @@ -601,6 +622,10 @@ class Command(_BaseCommand, Generic[CogT, P, T]): # The greedy converter is simple -- it keeps going until it fails in which case, # it undos the view ready for the next parameter to use instead if isinstance(converter, Greedy): + # Special case for Greedy[discord.Attachment] to consume the attachments iterator + if converter.converter is discord.Attachment: + return list(attachments) + if param.kind in (param.POSITIONAL_OR_KEYWORD, param.POSITIONAL_ONLY): return await self._transform_greedy_pos(ctx, param, param.required, converter.converter) elif param.kind == param.VAR_POSITIONAL: @@ -611,6 +636,20 @@ class Command(_BaseCommand, Generic[CogT, P, T]): # into just X and do the parsing that way. converter = converter.converter + # Try to detect Optional[discord.Attachment] or discord.Attachment special converter + if converter is discord.Attachment: + try: + return next(attachments) + except StopIteration: + raise MissingRequiredAttachment(param) + + if self._is_typing_optional(param.annotation) and param.annotation.__args__[0] is discord.Attachment: + if attachments.is_empty(): + # I have no idea who would be doing Optional[discord.Attachment] = 1 + # but for those cases then 1 should be returned instead of None + return None if param.default is param.empty else param.default + return next(attachments) + if view.eof: if param.kind == param.VAR_POSITIONAL: raise RuntimeError() # break the loop @@ -759,6 +798,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): ctx.kwargs = {} args = ctx.args kwargs = ctx.kwargs + attachments = _AttachmentIterator(ctx.message.attachments) view = ctx.view iterator = iter(self.params.items()) @@ -766,7 +806,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): for name, param in iterator: ctx.current_parameter = param if param.kind in (param.POSITIONAL_OR_KEYWORD, param.POSITIONAL_ONLY): - transformed = await self.transform(ctx, param) + transformed = await self.transform(ctx, param, attachments) args.append(transformed) elif param.kind == param.KEYWORD_ONLY: # kwarg only param denotes "consume rest" semantics @@ -774,14 +814,14 @@ class Command(_BaseCommand, Generic[CogT, P, T]): ctx.current_argument = argument = view.read_rest() kwargs[name] = await run_converters(ctx, param.converter, argument, param) else: - kwargs[name] = await self.transform(ctx, param) + kwargs[name] = await self.transform(ctx, param, attachments) break elif param.kind == param.VAR_POSITIONAL: if view.eof and self.require_var_positional: raise MissingRequiredArgument(param) while not view.eof: try: - transformed = await self.transform(ctx, param) + transformed = await self.transform(ctx, param, attachments) args.append(transformed) except RuntimeError: break @@ -1080,7 +1120,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): return self.help.split('\n', 1)[0] return '' - def _is_typing_optional(self, annotation: Union[T, Optional[T]]) -> TypeGuard[Optional[T]]: + def _is_typing_optional(self, annotation: Union[T, Optional[T]]) -> bool: return getattr(annotation, '__origin__', None) is Union and type(None) in annotation.__args__ # type: ignore @property @@ -1108,6 +1148,17 @@ class Command(_BaseCommand, Generic[CogT, P, T]): annotation = union_args[0] origin = getattr(annotation, '__origin__', None) + if annotation is discord.Attachment: + # For discord.Attachment we need to signal to the user that it's an attachment + # It's not exactly pretty but it's enough to differentiate + if optional: + result.append(f'[{name} (upload a file)]') + elif greedy: + result.append(f'[{name} (upload files)]...') + else: + result.append(f'<{name} (upload a file)>') + continue + # for typing.Literal[...], typing.Optional[typing.Literal[...]], and Greedy[typing.Literal[...]], the # parameter signature is a literal list of it's values if origin is Literal: diff --git a/discord/ext/commands/errors.py b/discord/ext/commands/errors.py index a55088545..1a912b2e9 100644 --- a/discord/ext/commands/errors.py +++ b/discord/ext/commands/errors.py @@ -45,6 +45,7 @@ if TYPE_CHECKING: __all__ = ( 'CommandError', 'MissingRequiredArgument', + 'MissingRequiredAttachment', 'BadArgument', 'PrivateMessageOnly', 'NoPrivateMessage', @@ -184,6 +185,25 @@ class MissingRequiredArgument(UserInputError): super().__init__(f'{param.name} is a required argument that is missing.') +class MissingRequiredAttachment(UserInputError): + """Exception raised when parsing a command and a parameter + that requires an attachment is not given. + + This inherits from :exc:`UserInputError` + + .. versionadded:: 2.0 + + Attributes + ----------- + param: :class:`Parameter` + The argument that is missing an attachment. + """ + + def __init__(self, param: Parameter) -> None: + self.param: Parameter = param + super().__init__(f'{param.name} is a required argument that is missing an attachment.') + + class TooManyArguments(UserInputError): """Exception raised when the command was passed too many arguments and its :attr:`.Command.ignore_extra` attribute was not set to ``True``. diff --git a/discord/ext/commands/hybrid.py b/discord/ext/commands/hybrid.py index b32563221..555a3ef39 100644 --- a/discord/ext/commands/hybrid.py +++ b/discord/ext/commands/hybrid.py @@ -186,6 +186,9 @@ def replace_parameters(parameters: Dict[str, Parameter], signature: inspect.Sign # However, in here, it probably makes sense to make it required. # I'm unsure how to allow the user to choose right now. inner = converter.converter + if inner is discord.Attachment: + raise TypeError('discord.Attachment with Greedy is not supported in hybrid commands') + param = param.replace(annotation=make_greedy_transformer(inner, parameter)) elif is_converter(converter): param = param.replace(annotation=make_converter_transformer(converter)) diff --git a/docs/ext/commands/api.rst b/docs/ext/commands/api.rst index bf3706adf..63dad2717 100644 --- a/docs/ext/commands/api.rst +++ b/docs/ext/commands/api.rst @@ -538,6 +538,9 @@ Exceptions .. autoexception:: discord.ext.commands.MissingRequiredArgument :members: +.. autoexception:: discord.ext.commands.MissingRequiredAttachment + :members: + .. autoexception:: discord.ext.commands.ArgumentParsingError :members: @@ -714,6 +717,7 @@ Exception Hierarchy - :exc:`~.commands.ConversionError` - :exc:`~.commands.UserInputError` - :exc:`~.commands.MissingRequiredArgument` + - :exc:`~.commands.MissingRequiredAttachment` - :exc:`~.commands.TooManyArguments` - :exc:`~.commands.BadArgument` - :exc:`~.commands.MessageNotFound` diff --git a/docs/ext/commands/commands.rst b/docs/ext/commands/commands.rst index a010232bc..dcd06b6fd 100644 --- a/docs/ext/commands/commands.rst +++ b/docs/ext/commands/commands.rst @@ -639,6 +639,85 @@ This command can be invoked any of the following ways: To help aid with some parsing ambiguities, :class:`str`, ``None``, :data:`typing.Optional` and :class:`~ext.commands.Greedy` are forbidden as parameters for the :class:`~ext.commands.Greedy` converter. + +discord.Attachment +^^^^^^^^^^^^^^^^^^^ + +.. versionadded:: 2.0 + +The :class:`discord.Attachment` converter is a special converter that retrieves an attachment from the uploaded attachments on a message. This converter *does not* look at the message content at all and just the uploaded attachments. + +Consider the following example: + +.. code-block:: python3 + + import discord + + @bot.command() + async def upload(ctx, attachment: discord.Attachment): + await ctx.send(f'You have uploaded <{attachment.url}>') + + +When this command is invoked, the user must directly upload a file for the command body to be executed. When combined with the :data:`typing.Optional` converter, the user does not have to provide an attachment. + +.. code-block:: python3 + + import typing + import discord + + @bot.command() + async def upload(ctx, attachment: typing.Optional[discord.Attachment]): + if attachment is None: + await ctx.send('You did not upload anything!') + else: + await ctx.send(f'You have uploaded <{attachment.url}>') + + +This also works with multiple attachments: + +.. code-block:: python3 + + import typing + import discord + + @bot.command() + async def upload_many( + ctx, + first: discord.Attachment, + second: typing.Optional[discord.Attachment], + ): + if second is None: + files = [first.url] + else: + files = [first.url, second.url] + + await ctx.send(f'You uploaded: {" ".join(files)}') + + +In this example the user must provide at least one file but the second one is optional. + +As a special case, using :class:`~ext.commands.Greedy` will return the remaining attachments in the message, if any. + +.. code-block:: python3 + + import discord + from discord.ext import commands + + @bot.command() + async def upload_many( + ctx, + first: discord.Attachment, + remaining: commands.Greedy[discord.Attachment], + ): + files = [first.url] + files.extend(a.url for a in remaining) + await ctx.send(f'You uploaded: {" ".join(files)}') + + +Note that using a :class:`discord.Attachment` converter after a :class:`~ext.commands.Greedy` of :class:`discord.Attachment` will always fail since the greedy had already consumed the remaining attachments. + +If an attachment is expected but not given, then :exc:`~ext.commands.MissingRequiredAttachment` is raised to the error handlers. + .. _ext_commands_flag_converter: FlagConverter