Skip to content
This repository was archived by the owner on Aug 28, 2019. It is now read-only.

Commit 9793fba

Browse files
committed
[commands] Add support for discord.Attachment converters
1 parent d884657 commit 9793fba

File tree

5 files changed

+163
-6
lines changed

5 files changed

+163
-6
lines changed

discord/ext/commands/core.py

+57-6
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
from .parameters import Parameter, Signature
5757

5858
if TYPE_CHECKING:
59-
from typing_extensions import Concatenate, ParamSpec, Self, TypeGuard
59+
from typing_extensions import Concatenate, ParamSpec, Self
6060

6161
from discord.message import Message
6262

@@ -237,6 +237,27 @@ def __setitem__(self, k, v):
237237
super().__setitem__(k.casefold(), v)
238238

239239

240+
class _AttachmentIterator:
241+
def __init__(self, data: List[discord.Attachment]):
242+
self.data: List[discord.Attachment] = data
243+
self.index: int = 0
244+
245+
def __iter__(self) -> Self:
246+
return self
247+
248+
def __next__(self) -> discord.Attachment:
249+
try:
250+
value = self.data[self.index]
251+
except IndexError:
252+
raise StopIteration
253+
else:
254+
self.index += 1
255+
return value
256+
257+
def is_empty(self) -> bool:
258+
return self.index >= len(self.data)
259+
260+
240261
class Command(_BaseCommand, Generic[CogT, P, T]):
241262
r"""A class that implements the protocol for a bot text command.
242263
@@ -592,7 +613,7 @@ async def dispatch_error(self, ctx: Context[BotT], error: CommandError, /) -> No
592613
finally:
593614
ctx.bot.dispatch('command_error', ctx, error)
594615

595-
async def transform(self, ctx: Context[BotT], param: Parameter, /) -> Any:
616+
async def transform(self, ctx: Context[BotT], param: Parameter, attachments: _AttachmentIterator, /) -> Any:
596617
converter = param.converter
597618
consume_rest_is_special = param.kind == param.KEYWORD_ONLY and not self.rest_is_raw
598619
view = ctx.view
@@ -601,6 +622,10 @@ async def transform(self, ctx: Context[BotT], param: Parameter, /) -> Any:
601622
# The greedy converter is simple -- it keeps going until it fails in which case,
602623
# it undos the view ready for the next parameter to use instead
603624
if isinstance(converter, Greedy):
625+
# Special case for Greedy[discord.Attachment] to consume the attachments iterator
626+
if converter.converter is discord.Attachment:
627+
return list(attachments)
628+
604629
if param.kind in (param.POSITIONAL_OR_KEYWORD, param.POSITIONAL_ONLY):
605630
return await self._transform_greedy_pos(ctx, param, param.required, converter.converter)
606631
elif param.kind == param.VAR_POSITIONAL:
@@ -611,6 +636,20 @@ async def transform(self, ctx: Context[BotT], param: Parameter, /) -> Any:
611636
# into just X and do the parsing that way.
612637
converter = converter.converter
613638

639+
# Try to detect Optional[discord.Attachment] or discord.Attachment special converter
640+
if converter is discord.Attachment:
641+
try:
642+
return next(attachments)
643+
except StopIteration:
644+
raise MissingRequiredAttachment(param)
645+
646+
if self._is_typing_optional(param.annotation) and param.annotation.__args__[0] is discord.Attachment:
647+
if attachments.is_empty():
648+
# I have no idea who would be doing Optional[discord.Attachment] = 1
649+
# but for those cases then 1 should be returned instead of None
650+
return None if param.default is param.empty else param.default
651+
return next(attachments)
652+
614653
if view.eof:
615654
if param.kind == param.VAR_POSITIONAL:
616655
raise RuntimeError() # break the loop
@@ -759,29 +798,30 @@ async def _parse_arguments(self, ctx: Context[BotT]) -> None:
759798
ctx.kwargs = {}
760799
args = ctx.args
761800
kwargs = ctx.kwargs
801+
attachments = _AttachmentIterator(ctx.message.attachments)
762802

763803
view = ctx.view
764804
iterator = iter(self.params.items())
765805

766806
for name, param in iterator:
767807
ctx.current_parameter = param
768808
if param.kind in (param.POSITIONAL_OR_KEYWORD, param.POSITIONAL_ONLY):
769-
transformed = await self.transform(ctx, param)
809+
transformed = await self.transform(ctx, param, attachments)
770810
args.append(transformed)
771811
elif param.kind == param.KEYWORD_ONLY:
772812
# kwarg only param denotes "consume rest" semantics
773813
if self.rest_is_raw:
774814
ctx.current_argument = argument = view.read_rest()
775815
kwargs[name] = await run_converters(ctx, param.converter, argument, param)
776816
else:
777-
kwargs[name] = await self.transform(ctx, param)
817+
kwargs[name] = await self.transform(ctx, param, attachments)
778818
break
779819
elif param.kind == param.VAR_POSITIONAL:
780820
if view.eof and self.require_var_positional:
781821
raise MissingRequiredArgument(param)
782822
while not view.eof:
783823
try:
784-
transformed = await self.transform(ctx, param)
824+
transformed = await self.transform(ctx, param, attachments)
785825
args.append(transformed)
786826
except RuntimeError:
787827
break
@@ -1080,7 +1120,7 @@ def short_doc(self) -> str:
10801120
return self.help.split('\n', 1)[0]
10811121
return ''
10821122

1083-
def _is_typing_optional(self, annotation: Union[T, Optional[T]]) -> TypeGuard[Optional[T]]:
1123+
def _is_typing_optional(self, annotation: Union[T, Optional[T]]) -> bool:
10841124
return getattr(annotation, '__origin__', None) is Union and type(None) in annotation.__args__ # type: ignore
10851125

10861126
@property
@@ -1108,6 +1148,17 @@ def signature(self) -> str:
11081148
annotation = union_args[0]
11091149
origin = getattr(annotation, '__origin__', None)
11101150

1151+
if annotation is discord.Attachment:
1152+
# For discord.Attachment we need to signal to the user that it's an attachment
1153+
# It's not exactly pretty but it's enough to differentiate
1154+
if optional:
1155+
result.append(f'[{name} (upload a file)]')
1156+
elif greedy:
1157+
result.append(f'[{name} (upload files)]...')
1158+
else:
1159+
result.append(f'<{name} (upload a file)>')
1160+
continue
1161+
11111162
# for typing.Literal[...], typing.Optional[typing.Literal[...]], and Greedy[typing.Literal[...]], the
11121163
# parameter signature is a literal list of it's values
11131164
if origin is Literal:

discord/ext/commands/errors.py

+20
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
__all__ = (
4646
'CommandError',
4747
'MissingRequiredArgument',
48+
'MissingRequiredAttachment',
4849
'BadArgument',
4950
'PrivateMessageOnly',
5051
'NoPrivateMessage',
@@ -184,6 +185,25 @@ def __init__(self, param: Parameter) -> None:
184185
super().__init__(f'{param.name} is a required argument that is missing.')
185186

186187

188+
class MissingRequiredAttachment(UserInputError):
189+
"""Exception raised when parsing a command and a parameter
190+
that requires an attachment is not given.
191+
192+
This inherits from :exc:`UserInputError`
193+
194+
.. versionadded:: 2.0
195+
196+
Attributes
197+
-----------
198+
param: :class:`Parameter`
199+
The argument that is missing an attachment.
200+
"""
201+
202+
def __init__(self, param: Parameter) -> None:
203+
self.param: Parameter = param
204+
super().__init__(f'{param.name} is a required argument that is missing an attachment.')
205+
206+
187207
class TooManyArguments(UserInputError):
188208
"""Exception raised when the command was passed too many arguments and its
189209
:attr:`.Command.ignore_extra` attribute was not set to ``True``.

discord/ext/commands/hybrid.py

+3
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,9 @@ def replace_parameters(parameters: Dict[str, Parameter], signature: inspect.Sign
186186
# However, in here, it probably makes sense to make it required.
187187
# I'm unsure how to allow the user to choose right now.
188188
inner = converter.converter
189+
if inner is discord.Attachment:
190+
raise TypeError('discord.Attachment with Greedy is not supported in hybrid commands')
191+
189192
param = param.replace(annotation=make_greedy_transformer(inner, parameter))
190193
elif is_converter(converter):
191194
param = param.replace(annotation=make_converter_transformer(converter))

docs/ext/commands/api.rst

+4
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,9 @@ Exceptions
538538
.. autoexception:: discord.ext.commands.MissingRequiredArgument
539539
:members:
540540

541+
.. autoexception:: discord.ext.commands.MissingRequiredAttachment
542+
:members:
543+
541544
.. autoexception:: discord.ext.commands.ArgumentParsingError
542545
:members:
543546

@@ -714,6 +717,7 @@ Exception Hierarchy
714717
- :exc:`~.commands.ConversionError`
715718
- :exc:`~.commands.UserInputError`
716719
- :exc:`~.commands.MissingRequiredArgument`
720+
- :exc:`~.commands.MissingRequiredAttachment`
717721
- :exc:`~.commands.TooManyArguments`
718722
- :exc:`~.commands.BadArgument`
719723
- :exc:`~.commands.MessageNotFound`

docs/ext/commands/commands.rst

+79
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,85 @@ This command can be invoked any of the following ways:
639639
To help aid with some parsing ambiguities, :class:`str`, ``None``, :data:`typing.Optional` and
640640
:class:`~ext.commands.Greedy` are forbidden as parameters for the :class:`~ext.commands.Greedy` converter.
641641

642+
643+
discord.Attachment
644+
^^^^^^^^^^^^^^^^^^^
645+
646+
.. versionadded:: 2.0
647+
648+
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.
649+
650+
Consider the following example:
651+
652+
.. code-block:: python3
653+
654+
import discord
655+
656+
@bot.command()
657+
async def upload(ctx, attachment: discord.Attachment):
658+
await ctx.send(f'You have uploaded <{attachment.url}>')
659+
660+
661+
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.
662+
663+
.. code-block:: python3
664+
665+
import typing
666+
import discord
667+
668+
@bot.command()
669+
async def upload(ctx, attachment: typing.Optional[discord.Attachment]):
670+
if attachment is None:
671+
await ctx.send('You did not upload anything!')
672+
else:
673+
await ctx.send(f'You have uploaded <{attachment.url}>')
674+
675+
676+
This also works with multiple attachments:
677+
678+
.. code-block:: python3
679+
680+
import typing
681+
import discord
682+
683+
@bot.command()
684+
async def upload_many(
685+
ctx,
686+
first: discord.Attachment,
687+
second: typing.Optional[discord.Attachment],
688+
):
689+
if second is None:
690+
files = [first.url]
691+
else:
692+
files = [first.url, second.url]
693+
694+
await ctx.send(f'You uploaded: {" ".join(files)}')
695+
696+
697+
In this example the user must provide at least one file but the second one is optional.
698+
699+
As a special case, using :class:`~ext.commands.Greedy` will return the remaining attachments in the message, if any.
700+
701+
.. code-block:: python3
702+
703+
import discord
704+
from discord.ext import commands
705+
706+
@bot.command()
707+
async def upload_many(
708+
ctx,
709+
first: discord.Attachment,
710+
remaining: commands.Greedy[discord.Attachment],
711+
):
712+
files = [first.url]
713+
files.extend(a.url for a in remaining)
714+
await ctx.send(f'You uploaded: {" ".join(files)}')
715+
716+
717+
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.
718+
719+
If an attachment is expected but not given, then :exc:`~ext.commands.MissingRequiredAttachment` is raised to the error handlers.
720+
642721
.. _ext_commands_flag_converter:
643722

644723
FlagConverter

0 commit comments

Comments
 (0)