Skip to content

Commit 6646129

Browse files
authored
Merge pull request #462 from Pycord-Development/groups
Redesigning slash command groups
2 parents 125db3a + 27d0f6c commit 6646129

File tree

5 files changed

+241
-11
lines changed

5 files changed

+241
-11
lines changed

discord/bot.py

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ def add_application_command(self, command: ApplicationCommand) -> None:
118118
command: :class:`.ApplicationCommand`
119119
The command to add.
120120
"""
121+
if isinstance(command, SlashCommand) and command.is_subcommand:
122+
raise TypeError("The provided command is a sub-command of group")
123+
121124
if self.debug_guilds and command.guild_ids is None:
122125
command.guild_ids = self.debug_guilds
123126
self._pending_application_commands.append(command)
@@ -508,7 +511,6 @@ def application_command(self, **kwargs):
508511
"""
509512

510513
def decorator(func) -> ApplicationCommand:
511-
kwargs.setdefault("parent", self)
512514
result = command(**kwargs)(func)
513515
self.add_application_command(result)
514516
return result
@@ -532,14 +534,78 @@ def command(self, **kwargs):
532534
"""
533535
return self.application_command(**kwargs)
534536

535-
def command_group(
536-
self, name: str, description: str, guild_ids=None
537+
def create_group(
538+
self,
539+
name: str,
540+
description: Optional[str] = None,
541+
guild_ids: Optional[List[int]] = None,
537542
) -> SlashCommandGroup:
538-
# TODO: Write documentation for this. I'm not familiar enough with what this function does to do it myself.
543+
"""A shortcut method that creates a slash command group with no subcommands and adds it to the internal
544+
command list via :meth:`~.ApplicationCommandMixin.add_application_command`.
545+
546+
.. versionadded:: 2.0
547+
548+
Parameters
549+
----------
550+
name: :class:`str`
551+
The name of the group to create.
552+
description: Optional[:class:`str`]
553+
The description of the group to create.
554+
guild_ids: Optional[List[:class:`int`]]
555+
A list of the IDs of each guild this group should be added to, making it a guild command.
556+
This will be a global command if ``None`` is passed.
557+
558+
Returns
559+
--------
560+
SlashCommandGroup
561+
The slash command group that was created.
562+
"""
563+
description = description or "No description provided."
539564
group = SlashCommandGroup(name, description, guild_ids)
540565
self.add_application_command(group)
541566
return group
542567

568+
def group(
569+
self,
570+
name: str,
571+
description: Optional[str] = None,
572+
guild_ids: Optional[List[int]] = None,
573+
) -> Callable[[Type[SlashCommandGroup]], SlashCommandGroup]:
574+
"""A shortcut decorator that initializes the provided subclass of :class:`.SlashCommandGroup`
575+
and adds it to the internal command list via :meth:`~.ApplicationCommandMixin.add_application_command`.
576+
577+
.. versionadded:: 2.0
578+
579+
Parameters
580+
----------
581+
name: :class:`str`
582+
The name of the group to create.
583+
description: Optional[:class:`str`]
584+
The description of the group to create.
585+
guild_ids: Optional[List[:class:`int`]]
586+
A list of the IDs of each guild this group should be added to, making it a guild command.
587+
This will be a global command if ``None`` is passed.
588+
589+
Returns
590+
--------
591+
Callable[[Type[SlashCommandGroup]], SlashCommandGroup]
592+
The slash command group that was created.
593+
"""
594+
def inner(cls: Type[SlashCommandGroup]) -> SlashCommandGroup:
595+
group = cls(
596+
name,
597+
(
598+
description or inspect.cleandoc(cls.__doc__).splitlines()[0]
599+
if cls.__doc__ is not None else "No description provided"
600+
),
601+
guild_ids=guild_ids
602+
)
603+
self.add_application_command(group)
604+
return group
605+
return inner
606+
607+
slash_group = group
608+
543609
async def get_application_context(
544610
self, interaction: Interaction, cls=None
545611
) -> ApplicationContext:

discord/cog.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
import discord.utils
3030
import types
3131
from . import errors
32-
from .commands import SlashCommand, UserCommand, MessageCommand, ApplicationCommand
32+
from .commands import SlashCommand, UserCommand, MessageCommand, ApplicationCommand, SlashCommandGroup
3333

3434
from typing import Any, Callable, Mapping, ClassVar, Dict, Generator, List, Optional, TYPE_CHECKING, Tuple, TypeVar, Type
3535

@@ -145,6 +145,13 @@ def __new__(cls: Type[CogMeta], *args: Any, **kwargs: Any) -> CogMeta:
145145
if elem in listeners:
146146
del listeners[elem]
147147

148+
try:
149+
if getattr(value, "parent") is not None:
150+
# Skip commands if they are a part of a group
151+
continue
152+
except AttributeError:
153+
pass
154+
148155
is_static_method = isinstance(value, staticmethod)
149156
if is_static_method:
150157
value = value.__func__
@@ -445,7 +452,8 @@ def _inject(self: CogT, bot) -> CogT:
445452
# we've added so far for some form of atomic loading.
446453

447454
for index, command in enumerate(self.__cog_commands__):
448-
command.cog = self
455+
command._set_cog(self)
456+
449457
if not isinstance(command, ApplicationCommand):
450458
if command.parent is None:
451459
try:

discord/commands/commands.py

Lines changed: 116 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
import re
3232
import types
3333
from collections import OrderedDict
34-
from typing import Any, Callable, Dict, List, Optional, Union, TYPE_CHECKING
34+
from typing import Any, Callable, Dict, List, Optional, Type, Union, TYPE_CHECKING
3535

3636
from .context import ApplicationContext, AutocompleteContext
3737
from .errors import ApplicationCommandError, CheckFailure, ApplicationCommandInvokeError
@@ -318,6 +318,9 @@ def qualified_name(self) -> str:
318318
else:
319319
return self.name
320320

321+
def _set_cog(self, cog):
322+
self.cog = cog
323+
321324
class SlashCommand(ApplicationCommand):
322325
r"""A class that implements the protocol for a slash command.
323326
@@ -386,7 +389,7 @@ def __init__(self, func: Callable, *args, **kwargs) -> None:
386389
validate_chat_input_description(description)
387390
self.description: str = description
388391
self.parent = kwargs.get('parent')
389-
self.is_subcommand: bool = self.parent is not None
392+
self.attached_to_group: bool = False
390393

391394
self.cog = None
392395

@@ -476,6 +479,10 @@ def _is_typing_union(self, annotation):
476479
def _is_typing_optional(self, annotation):
477480
return self._is_typing_union(annotation) and type(None) in annotation.__args__ # type: ignore
478481

482+
@property
483+
def is_subcommand(self) -> bool:
484+
return self.parent is not None
485+
479486
def to_dict(self) -> Dict:
480487
as_dict = {
481488
"name": self.name,
@@ -528,6 +535,8 @@ async def _invoke(self, ctx: ApplicationContext) -> None:
528535

529536
if self.cog is not None:
530537
await self.callback(self.cog, ctx, **kwargs)
538+
elif self.parent is not None and self.attached_to_group is True:
539+
await self.callback(self.parent, ctx, **kwargs)
531540
else:
532541
await self.callback(ctx, **kwargs)
533542

@@ -729,8 +738,24 @@ class SlashCommandGroup(ApplicationCommand, Option):
729738

730739
def __new__(cls, *args, **kwargs) -> SlashCommandGroup:
731740
self = super().__new__(cls)
732-
733741
self.__original_kwargs__ = kwargs.copy()
742+
743+
self.__initial_commands__ = []
744+
for i, c in cls.__dict__.items():
745+
if isinstance(c, type) and SlashCommandGroup in c.__bases__:
746+
c = c(
747+
c.__name__,
748+
(
749+
inspect.cleandoc(cls.__doc__).splitlines()[0]
750+
if cls.__doc__ is not None
751+
else "No description provided"
752+
)
753+
)
754+
if isinstance(c, (SlashCommand, SlashCommandGroup)):
755+
c.parent = self
756+
c.attached_to_group = True
757+
self.__initial_commands__.append(c)
758+
734759
return self
735760

736761
def __init__(
@@ -748,7 +773,7 @@ def __init__(
748773
name=name,
749774
description=description,
750775
)
751-
self.subcommands: List[Union[SlashCommand, SlashCommandGroup]] = []
776+
self.subcommands: List[Union[SlashCommand, SlashCommandGroup]] = self.__initial_commands__
752777
self.guild_ids = guild_ids
753778
self.parent = parent
754779
self.checks = []
@@ -768,6 +793,7 @@ def to_dict(self) -> Dict:
768793
"name": self.name,
769794
"description": self.description,
770795
"options": [c.to_dict() for c in self.subcommands],
796+
"default_permission": self.default_permission,
771797
}
772798

773799
if self.parent is not None:
@@ -783,7 +809,7 @@ def wrap(func) -> SlashCommand:
783809

784810
return wrap
785811

786-
def command_group(self, name, description) -> SlashCommandGroup:
812+
def create_subgroup(self, name, description) -> SlashCommandGroup:
787813
if self.parent is not None:
788814
# TODO: Improve this error message
789815
raise Exception("Subcommands can only be nested once")
@@ -792,6 +818,47 @@ def command_group(self, name, description) -> SlashCommandGroup:
792818
self.subcommands.append(sub_command_group)
793819
return sub_command_group
794820

821+
def subgroup(
822+
self,
823+
name: str,
824+
description: str = None,
825+
guild_ids: Optional[List[int]] = None,
826+
) -> Callable[[Type[SlashCommandGroup]], SlashCommandGroup]:
827+
"""A shortcut decorator that initializes the provided subclass of :class:`.SlashCommandGroup`
828+
as a subgroup.
829+
830+
.. versionadded:: 2.0
831+
832+
Parameters
833+
----------
834+
name: :class:`str`
835+
The name of the group to create.
836+
description: Optional[:class:`str`]
837+
The description of the group to create.
838+
guild_ids: Optional[List[:class:`int`]]
839+
A list of the IDs of each guild this group should be added to, making it a guild command.
840+
This will be a global command if ``None`` is passed.
841+
842+
Returns
843+
--------
844+
Callable[[Type[SlashCommandGroup]], SlashCommandGroup]
845+
The slash command group that was created.
846+
"""
847+
def inner(cls: Type[SlashCommandGroup]) -> SlashCommandGroup:
848+
group = cls(
849+
name,
850+
description or (
851+
inspect.cleandoc(cls.__doc__).splitlines()[0]
852+
if cls.__doc__ is not None
853+
else "No description provided"
854+
),
855+
guild_ids=guild_ids,
856+
parent=self,
857+
)
858+
self.subcommands.append(group)
859+
return group
860+
return inner
861+
795862
async def _invoke(self, ctx: ApplicationContext) -> None:
796863
option = ctx.interaction.data["options"][0]
797864
command = find(lambda x: x.name == option["name"], self.subcommands)
@@ -804,6 +871,50 @@ async def invoke_autocomplete_callback(self, ctx: AutocompleteContext) -> None:
804871
ctx.interaction.data = option
805872
await command.invoke_autocomplete_callback(ctx)
806873

874+
def copy(self):
875+
"""Creates a copy of this command group.
876+
877+
Returns
878+
--------
879+
:class:`SlashCommandGroup`
880+
A new instance of this command.
881+
"""
882+
ret = self.__class__(
883+
self.name,
884+
self.description,
885+
**self.__original_kwargs__,
886+
)
887+
return self._ensure_assignment_on_copy(ret)
888+
889+
def _ensure_assignment_on_copy(self, other):
890+
other.parent = self.parent
891+
892+
other._before_invoke = self._before_invoke
893+
other._after_invoke = self._after_invoke
894+
895+
if self.subcommands != other.subcommands:
896+
other.subcommands = self.subcommands.copy()
897+
898+
if self.checks != other.checks:
899+
other.checks = self.checks.copy()
900+
901+
return other
902+
903+
def _update_copy(self, kwargs: Dict[str, Any]):
904+
if kwargs:
905+
kw = kwargs.copy()
906+
kw.update(self.__original_kwargs__)
907+
copy = self.__class__(self.callback, **kw)
908+
return self._ensure_assignment_on_copy(copy)
909+
else:
910+
return self.copy()
911+
912+
def _set_cog(self, cog):
913+
self.cog = cog
914+
for subcommand in self.subcommands:
915+
subcommand._set_cog(cog)
916+
917+
807918

808919
class ContextMenuCommand(ApplicationCommand):
809920
r"""A class that implements the protocol for context menu commands.

discord/ext/commands/core.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,6 +1144,9 @@ async def can_run(self, ctx: Context) -> bool:
11441144
finally:
11451145
ctx.command = original
11461146

1147+
def _set_cog(self, cog):
1148+
self.cog = cog
1149+
11471150
class GroupMixin(Generic[CogT]):
11481151
"""A mixin that implements common functionality for classes that behave
11491152
similar to :class:`.Group` and are allowed to register commands.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import discord
2+
from discord.commands import SlashCommandGroup, Permission
3+
from discord.ext import commands
4+
5+
bot = discord.Bot(debug_guild=..., owner_id=...) # main file
6+
7+
8+
class Example(commands.Cog):
9+
def __init__(self, bot):
10+
self.bot = bot
11+
12+
greetings = SlashCommandGroup("greetings", "Various greeting from cogs!")
13+
14+
international_greetings = greetings.create_subgroup(
15+
"international", "International greetings"
16+
)
17+
18+
secret_greetings = SlashCommandGroup(
19+
"secret_greetings",
20+
"Secret greetings",
21+
permissions=[
22+
Permission(
23+
"owner", 2, True
24+
) # Ensures the owner_id user can access this, and no one else
25+
],
26+
)
27+
28+
@greetings.command()
29+
async def hello(self, ctx):
30+
await ctx.respond("Hello, this is a slash subcommand from a cog!")
31+
32+
@international_greetings.command()
33+
async def aloha(self, ctx):
34+
await ctx.respond("Aloha, a Hawaiian greeting")
35+
36+
@secret_greetings.command()
37+
async def secret_handshake(self, ctx, member: discord.Member):
38+
await ctx.respond(f"{member.mention} secret handshakes you")
39+
40+
41+
bot.add_cog(Example(bot)) # put in a setup function for cog files
42+
bot.run("TOKEN") # main file

0 commit comments

Comments
 (0)