@@ -29,6 +29,8 @@ class SlashCommand:
2929 :type client: Union[discord.Client, discord.ext.commands.Bot]
3030 :param sync_commands: Whether to sync commands automatically. Default `False`.
3131 :type sync_commands: bool
32+ :param debug_guild: Guild ID of guild to use for testing commands. Prevents setting global commands in favor of guild commands, which update instantly
33+ :type debug_guild: int
3234 :param delete_from_unused_guilds: If the bot should make a request to set no commands for guilds that haven't got any commands registered in :class:``SlashCommand``. Default `False`.
3335 :type delete_from_unused_guilds: bool
3436 :param sync_on_cog_reload: Whether to sync commands on cog reload. Default `False`.
@@ -44,6 +46,7 @@ class SlashCommand:
4446
4547 :ivar _discord: Discord client of this client.
4648 :ivar commands: Dictionary of the registered commands via :func:`.slash` decorator.
49+ :ivar menu_commands: Dictionary of the registered context menus via the :func:`.context_menu` decorator.
4750 :ivar req: :class:`.http.SlashCommandRequest` of this client.
4851 :ivar logger: Logger of this client.
4952 :ivar sync_commands: Whether to sync commands automatically.
@@ -55,18 +58,20 @@ def __init__(
5558 self ,
5659 client : typing .Union [discord .Client , commands .Bot ],
5760 sync_commands : bool = False ,
61+ debug_guild : typing .Optional [int ] = None ,
5862 delete_from_unused_guilds : bool = False ,
5963 sync_on_cog_reload : bool = False ,
6064 override_type : bool = False ,
6165 application_id : typing .Optional [int ] = None ,
6266 ):
6367 self ._discord = client
64- self .commands = {}
68+ self .commands = {"context" : {} }
6569 self .subcommands = {}
6670 self .components = {}
6771 self .logger = logging .getLogger ("discord_slash" )
6872 self .req = http .SlashCommandRequest (self .logger , self ._discord , application_id )
6973 self .sync_commands = sync_commands
74+ self .debug_guild = debug_guild
7075 self .sync_on_cog_reload = sync_on_cog_reload
7176
7277 if self .sync_commands :
@@ -266,12 +271,53 @@ async def to_dict(self):
266271 await self ._discord .wait_until_ready () # In case commands are still not registered to SlashCommand.
267272 all_guild_ids = []
268273 for x in self .commands :
274+ if x == "context" :
275+ # handle context menu separately.
276+ for _x in self .commands ["context" ]:
277+ _selected = self .commands ["context" ][_x ]
278+ for i in _selected .allowed_guild_ids :
279+ if i not in all_guild_ids :
280+ all_guild_ids .append (i )
281+ continue
269282 for i in self .commands [x ].allowed_guild_ids :
270283 if i not in all_guild_ids :
271284 all_guild_ids .append (i )
272285 cmds = {"global" : [], "guild" : {x : [] for x in all_guild_ids }}
273286 wait = {} # Before merging to return dict, let's first put commands to temporary dict.
274287 for x in self .commands :
288+ if x == "context" :
289+ # handle context menu separately.
290+ for _x in self .commands ["context" ]: # x is the new reference dict
291+ selected = self .commands ["context" ][_x ]
292+
293+ if selected .allowed_guild_ids :
294+ for y in selected .allowed_guild_ids :
295+ if y not in wait :
296+ wait [y ] = {}
297+ command_dict = {
298+ "name" : _x ,
299+ "options" : selected .options or [],
300+ "default_permission" : selected .default_permission ,
301+ "permissions" : {},
302+ "type" : selected ._type ,
303+ }
304+ if y in selected .permissions :
305+ command_dict ["permissions" ][y ] = selected .permissions [y ]
306+ wait [y ][x ] = copy .deepcopy (command_dict )
307+ else :
308+ if "global" not in wait :
309+ wait ["global" ] = {}
310+ command_dict = {
311+ "name" : _x ,
312+ "options" : selected .options or [],
313+ "default_permission" : selected .default_permission ,
314+ "permissions" : selected .permissions or {},
315+ "type" : selected ._type ,
316+ }
317+ wait ["global" ][x ] = copy .deepcopy (command_dict )
318+
319+ continue
320+
275321 selected = self .commands [x ]
276322 if selected .allowed_guild_ids :
277323 for y in selected .allowed_guild_ids :
@@ -283,7 +329,10 @@ async def to_dict(self):
283329 "options" : selected .options or [],
284330 "default_permission" : selected .default_permission ,
285331 "permissions" : {},
332+ "type" : selected ._type ,
286333 }
334+ if command_dict ["type" ] != 1 :
335+ command_dict .pop ("description" )
287336 if y in selected .permissions :
288337 command_dict ["permissions" ][y ] = selected .permissions [y ]
289338 wait [y ][x ] = copy .deepcopy (command_dict )
@@ -296,14 +345,20 @@ async def to_dict(self):
296345 "options" : selected .options or [],
297346 "default_permission" : selected .default_permission ,
298347 "permissions" : selected .permissions or {},
348+ "type" : selected ._type ,
299349 }
350+ if command_dict ["type" ] != 1 :
351+ command_dict .pop ("description" )
300352 wait ["global" ][x ] = copy .deepcopy (command_dict )
301353
302354 # Separated normal command add and subcommand add not to
303355 # merge subcommands to one. More info at Issue #88
304356 # https://github.com/eunwoo1104/discord-py-slash-command/issues/88
305357
306358 for x in self .commands :
359+ if x == "context" :
360+ continue # no menus have subcommands.
361+
307362 if not self .commands [x ].has_subcommands :
308363 continue
309364 tgt = self .subcommands [x ]
@@ -373,7 +428,8 @@ async def sync_all_commands(
373428 permissions_map = {}
374429 cmds = await self .to_dict ()
375430 self .logger .info ("Syncing commands..." )
376- cmds_formatted = {None : cmds ["global" ]}
431+ # if debug_guild is set, global commands get re-routed to the guild to update quickly
432+ cmds_formatted = {self .debug_guild : cmds ["global" ]}
377433 for guild in cmds ["guild" ]:
378434 cmds_formatted [guild ] = cmds ["guild" ][guild ]
379435
@@ -419,7 +475,7 @@ async def sync_all_commands(
419475 if ex .status == 400 :
420476 # catch bad requests
421477 cmd_nums = set (
422- re .findall (r"In\s(\d). " , ex .args [0 ])
478+ re .findall (r"^[\w-]{1,32}$ " , ex .args [0 ])
423479 ) # find all discords references to commands
424480 error_string = ex .args [0 ]
425481
@@ -589,6 +645,66 @@ def add_slash_command(
589645 self .logger .debug (f"Added command `{ name } `" )
590646 return obj
591647
648+ def _cog_ext_add_context_menu (self , target : int , name : str , guild_ids : list = None ):
649+ """
650+ Creates a new cog_based context menu command.
651+
652+ :param cmd: Command Coroutine.
653+ :type cmd: Coroutine
654+ :param name: The name of the command
655+ :type name: str
656+ :param _type: The context menu type.
657+ :type _type: int
658+ """
659+
660+ def add_context_menu (self , cmd , name : str , _type : int , guild_ids : list = None ):
661+ """
662+ Creates a new context menu command.
663+
664+ :param cmd: Command Coroutine.
665+ :type cmd: Coroutine
666+ :param name: The name of the command
667+ :type name: str
668+ :param _type: The context menu type.
669+ :type _type: int
670+ """
671+
672+ name = [name or cmd .__name__ ][0 ]
673+ guild_ids = guild_ids or []
674+
675+ if not all (isinstance (item , int ) for item in guild_ids ):
676+ raise error .IncorrectGuildIDType (
677+ f"The snowflake IDs { guild_ids } given are not a list of integers. Because of discord.py convention, please use integer IDs instead. Furthermore, the command '{ name } ' will be deactivated and broken until fixed."
678+ )
679+
680+ if name in self .commands ["context" ]:
681+ tgt = self .commands ["context" ][name ]
682+ if not tgt .has_subcommands :
683+ raise error .DuplicateCommand (name )
684+ has_subcommands = tgt .has_subcommands # noqa
685+ for x in tgt .allowed_guild_ids :
686+ if x not in guild_ids :
687+ guild_ids .append (x )
688+
689+ _cmd = {
690+ "default_permission" : None ,
691+ "has_permissions" : None ,
692+ "name" : name ,
693+ "type" : _type ,
694+ "func" : cmd ,
695+ "description" : "" ,
696+ "guild_ids" : guild_ids ,
697+ "api_options" : [],
698+ "connector" : {},
699+ "has_subcommands" : False ,
700+ "api_permissions" : {},
701+ }
702+
703+ obj = model .BaseCommandObject (name , cmd = _cmd , _type = _type )
704+ self .commands ["context" ][name ] = obj
705+ self .logger .debug (f"Added context command `{ name } `" )
706+ return obj
707+
592708 def add_subcommand (
593709 self ,
594710 cmd ,
@@ -911,6 +1027,34 @@ def wrapper(cmd):
9111027
9121028 return wrapper
9131029
1030+ def context_menu (self , * , target : int , name : str , guild_ids : list = None ):
1031+ """
1032+ Decorator that adds context menu commands.
1033+
1034+ :param target: The type of menu.
1035+ :type target: int
1036+ :param name: A name to register as the command in the menu.
1037+ :type name: str
1038+ :param guild_ids: A list of guild IDs to register the command under. Defaults to ``None``.
1039+ :type guild_ids: list
1040+ """
1041+
1042+ def wrapper (cmd ):
1043+ # _obj = self.add_slash_command(
1044+ # cmd,
1045+ # name,
1046+ # "",
1047+ # guild_ids
1048+ # )
1049+
1050+ # This has to call both, as its a arg-less menu.
1051+
1052+ obj = self .add_context_menu (cmd , name , target , guild_ids )
1053+
1054+ return obj
1055+
1056+ return wrapper
1057+
9141058 def add_component_callback (
9151059 self ,
9161060 callback : typing .Coroutine ,
@@ -1250,12 +1394,15 @@ async def on_socket_response(self, msg):
12501394
12511395 to_use = msg ["d" ]
12521396 interaction_type = to_use ["type" ]
1253- if interaction_type in (1 , 2 ):
1254- return await self ._on_slash (to_use )
1255- if interaction_type == 3 :
1256- return await self ._on_component (to_use )
1257-
1258- raise NotImplementedError
1397+ if interaction_type in (1 , 2 , 3 ) or msg ["s" ] == 5 :
1398+ await self ._on_slash (to_use )
1399+ await self ._on_context_menu (to_use )
1400+ try :
1401+ await self ._on_component (to_use ) # noqa
1402+ except KeyError :
1403+ pass # for some reason it complains about custom_id being an optional arg when it's fine?
1404+ return
1405+ # raise NotImplementedError
12591406
12601407 async def _on_component (self , to_use ):
12611408 ctx = context .ComponentContext (self .req , to_use , self ._discord , self .logger )
@@ -1314,6 +1461,34 @@ async def _on_slash(self, to_use):
13141461
13151462 await self .invoke_command (selected_cmd , ctx , args )
13161463
1464+ async def _on_context_menu (self , to_use ):
1465+ if to_use ["data" ]["name" ] in self .commands ["context" ]:
1466+ ctx = context .MenuContext (self .req , to_use , self ._discord , self .logger )
1467+ cmd_name = to_use ["data" ]["name" ]
1468+
1469+ if cmd_name not in self .commands ["context" ] and cmd_name in self .subcommands :
1470+ return # menus don't have subcommands you smooth brain
1471+
1472+ selected_cmd = self .commands ["context" ][cmd_name ]
1473+
1474+ if (
1475+ selected_cmd .allowed_guild_ids
1476+ and ctx .guild_id not in selected_cmd .allowed_guild_ids
1477+ ):
1478+ return
1479+
1480+ if selected_cmd .has_subcommands and not selected_cmd .func :
1481+ return await self .handle_subcommand (ctx , to_use )
1482+
1483+ if "options" in to_use ["data" ]:
1484+ for x in to_use ["data" ]["options" ]:
1485+ if "value" not in x :
1486+ return await self .handle_subcommand (ctx , to_use )
1487+
1488+ self ._discord .dispatch ("context_menu" , ctx )
1489+
1490+ await self .invoke_command (selected_cmd , ctx , args = {})
1491+
13171492 async def handle_subcommand (self , ctx : context .SlashContext , data : dict ):
13181493 """
13191494 Coroutine for handling subcommand.
0 commit comments