6161)
6262from interactions .client .smart_cache import GlobalCache
6363from interactions .client .utils import NullCache , FastJson
64+ from interactions .client .utils .input_utils import get_args , get_first_word
6465from interactions .client .utils .misc_utils import get_event_name , wrap_partial
6566from interactions .client .utils .serializer import to_image_data
6667from interactions .models import (
111112from interactions .models .internal .auto_defer import AutoDefer
112113from interactions .models .internal .callback import CallbackObject
113114from interactions .models .internal .command import BaseCommand
115+ from interactions .models .internal .prefixed_commands import PrefixedCommand , when_mentioned
114116from interactions .models .internal .context import (
115117 BaseContext ,
118+ PrefixedContext ,
116119 InteractionContext ,
117120 SlashContext ,
118121 ModalContext ,
@@ -255,7 +258,11 @@ class Client(
255258 send_command_tracebacks: Automatically send uncaught tracebacks if a command throws an exception
256259 send_not_ready_messages: Send a message to the user if they try to use a command before the client is ready
257260
261+ default_prefix: The default prefix to use. Defaults to `None`.
262+ generate_prefixes: An asynchronous function that takes in a `Client` and `Message` object and returns either a string or an iterable of strings.
263+
258264 auto_defer: AutoDefer: A system to automatically defer commands after a set duration
265+ prefixed_context: Type[PrefixedContext]: The object too instantiate for Prefixed Context
259266 interaction_context: Type[InteractionContext]: InteractionContext: The object to instantiate for Interaction Context
260267 component_context: Type[ComponentContext]: The object to instantiate for Component Context
261268 autocomplete_context: Type[AutocompleteContext]: The object to instantiate for Autocomplete Context
@@ -316,6 +323,14 @@ def __init__(
316323 status : Status = Status .ONLINE ,
317324 sync_ext : bool = True ,
318325 sync_interactions : bool = True ,
326+ prefixed_context : Type [BaseContext ] = PrefixedContext ,
327+ default_prefix : Optional [str | list [str ]] = None ,
328+ generate_prefixes : Optional [
329+ Callable [
330+ [Self , Message ],
331+ Coroutine [Any , Any , str | list [str ]],
332+ ]
333+ ] = None ,
319334 proxy_url : str | None = None ,
320335 proxy_auth : BasicAuth | tuple [str , str ] | None = None ,
321336 token : str | None = None ,
@@ -356,6 +371,23 @@ def __init__(
356371 self .auto_defer = auto_defer
357372 """A system to automatically defer commands after a set duration"""
358373 self .intents = intents if isinstance (intents , Intents ) else Intents (intents )
374+ self .default_prefix = default_prefix
375+
376+ if (
377+ default_prefix or (generate_prefixes and generate_prefixes != when_mentioned )
378+ ) and Intents .MESSAGE_CONTENT not in self .intents :
379+ self .logger .warning (
380+ "Prefixed commands will not work since the required intent is not set -> Requires:"
381+ f" { Intents .MESSAGE_CONTENT .__repr__ ()} or usage of the default mention prefix as the prefix"
382+ )
383+
384+ if default_prefix is None and generate_prefixes is None :
385+ # by default, use mentioning the bot as the prefix
386+ generate_prefixes = when_mentioned
387+
388+ self .generate_prefixes = ( # type: ignore
389+ generate_prefixes if generate_prefixes is not None else self .generate_prefixes
390+ )
359391
360392 # resources
361393 if isinstance (proxy_auth , tuple ):
@@ -368,6 +400,8 @@ def __init__(
368400 """The HTTP client to use when interacting with discord endpoints"""
369401
370402 # context factories
403+ self .prefixed_context : Type [BaseContext [Self ]] = prefixed_context
404+ """The object to instantiate for Prefixed Context"""
371405 self .interaction_context : Type [BaseContext [Self ]] = interaction_context
372406 """The object to instantiate for Interaction Context"""
373407 self .component_context : Type [BaseContext [Self ]] = component_context
@@ -417,6 +451,8 @@ def __init__(
417451 self ._app : Absent [Application ] = MISSING
418452
419453 # collections
454+ self .prefixed_commands : Dict [str , PrefixedCommand ] = {}
455+ """A dictionary of registered prefixed commands: `{name: command}`"""
420456 self .interactions_by_scope : Dict ["Snowflake_Type" , Dict [str , InteractionCommand ]] = {}
421457 """A dictionary of registered application commands: `{scope: [commands]}`"""
422458 self ._interaction_lookup : dict [str , InteractionCommand ] = {}
@@ -1364,6 +1400,28 @@ def add_listener(self, listener: Listener) -> None:
13641400 c_listener for c_listener in self .listeners [listener .event ] if not c_listener .is_default_listener
13651401 ]
13661402
1403+ def add_prefixed_command (self , command : PrefixedCommand ) -> None :
1404+ """
1405+ Add a prefixed command to the client.
1406+
1407+ Args:
1408+ command: The command to add.
1409+
1410+ """
1411+ if command .is_subcommand :
1412+ raise ValueError ("You cannot add subcommands to the client - add the base command instead." )
1413+
1414+ command ._parse_parameters ()
1415+
1416+ if self .prefixed_commands .get (command .name ):
1417+ raise ValueError (f"Duplicate command! Multiple commands share the name/alias: { command .name } ." )
1418+ self .prefixed_commands [command .name ] = command
1419+
1420+ for alias in command .aliases :
1421+ if self .prefixed_commands .get (alias ):
1422+ raise ValueError (f"Duplicate command! Multiple commands share the name/alias: { alias } ." )
1423+ self .prefixed_commands [alias ] = command
1424+
13671425 def add_interaction (self , command : InteractionCommand ) -> bool :
13681426 """
13691427 Add a slash command to the client.
@@ -1492,6 +1550,8 @@ def add_command(self, func: Callable) -> None:
14921550 self .add_component_callback (func )
14931551 elif isinstance (func , InteractionCommand ):
14941552 self .add_interaction (func )
1553+ elif isinstance (func , PrefixedCommand ):
1554+ self .add_prefixed_command (func )
14951555 elif isinstance (func , Listener ):
14961556 self .add_listener (func )
14971557 elif isinstance (func , GlobalAutoComplete ):
@@ -1891,6 +1951,105 @@ async def handle_pre_ready_response(self, data: dict) -> None:
18911951 interaction_id = data ["id" ],
18921952 )
18931953
1954+ @Listener .create ("raw_message_create" , is_default_listener = True )
1955+ async def _dispatch_prefixed_commands (self , event : RawGatewayEvent ) -> None : # noqa: C901
1956+ """Determine if a prefixed command is being triggered, and dispatch it."""
1957+ # don't waste time processing this if there are no prefixed commands
1958+ if not self .prefixed_commands :
1959+ return
1960+
1961+ data = event .data
1962+
1963+ # many bots will not have the message content intent, and so will not have content
1964+ # for most messages. since there's nothing for prefixed commands to work off of,
1965+ # we might as well not waste time
1966+ if not data .get ("content" ):
1967+ return
1968+
1969+ # webhooks and users labeled with the bot property are bots, and should be ignored
1970+ if data .get ("webhook_id" ) or data ["author" ].get ("bot" , False ):
1971+ return
1972+
1973+ # now, we've done the basic filtering out, but everything from here on out relies
1974+ # on a proper message object, so now we either hope its already in the cache or wait
1975+ # on the processor
1976+
1977+ # first, let's check the cache...
1978+ message = self .cache .get_message (int (data ["channel_id" ]), int (data ["id" ]))
1979+
1980+ # this huge if statement basically checks if the message hasn't been fully processed by
1981+ # the processor yet, which would mean that these fields aren't fully filled
1982+ if message and (
1983+ (not message ._guild_id and event .data .get ("guild_id" ))
1984+ or (message ._guild_id and not message .guild )
1985+ or not message .channel
1986+ ):
1987+ message = None
1988+
1989+ # if we didn't get a message, then we know we should wait for the message create event
1990+ if not message :
1991+ try :
1992+ # i think 2 seconds is a very generous timeout limit
1993+ msg_event : events .MessageCreate = await self .wait_for (
1994+ events .MessageCreate , checks = lambda e : int (e .message .id ) == int (data ["id" ]), timeout = 2
1995+ )
1996+ message = msg_event .message
1997+ except TimeoutError :
1998+ return
1999+
2000+ if not message .content :
2001+ return
2002+
2003+ # here starts the actual prefixed command parsing part
2004+ prefixes : str | Iterable [str ] = await self .generate_prefixes (self , message )
2005+
2006+ if isinstance (prefixes , str ):
2007+ # its easier to treat everything as if it may be an iterable
2008+ # rather than building a special case for this
2009+ prefixes = (prefixes ,) # type: ignore
2010+
2011+ prefix_used = next (
2012+ (prefix for prefix in prefixes if message .content .startswith (prefix )),
2013+ None ,
2014+ )
2015+ if not prefix_used :
2016+ return
2017+
2018+ context = self .prefixed_context .from_message (self , message )
2019+ context .prefix = prefix_used
2020+
2021+ content_parameters = message .content .removeprefix (prefix_used ).strip ()
2022+ command : "Self | PrefixedCommand" = self # yes, this is a hack
2023+
2024+ while True :
2025+ first_word : str = get_first_word (content_parameters ) # type: ignore
2026+ if isinstance (command , PrefixedCommand ):
2027+ new_command = command .subcommands .get (first_word )
2028+ else :
2029+ new_command = command .prefixed_commands .get (first_word )
2030+ if not new_command or not new_command .enabled :
2031+ break
2032+
2033+ command = new_command
2034+ content_parameters = content_parameters .removeprefix (first_word ).strip ()
2035+
2036+ if not isinstance (command , PrefixedCommand ) or not command .enabled :
2037+ return
2038+
2039+ context .command = command
2040+ context .content_parameters = content_parameters .strip ()
2041+ context .args = get_args (context .content_parameters )
2042+ try :
2043+ if self .pre_run_callback :
2044+ await self .pre_run_callback (context )
2045+ await command (context )
2046+ if self .post_run_callback :
2047+ await self .post_run_callback (context )
2048+ except Exception as e :
2049+ self .dispatch (events .CommandError (ctx = context , error = e ))
2050+ finally :
2051+ self .dispatch (events .CommandCompletion (ctx = context ))
2052+
18942053 async def _run_slash_command (self , command : SlashCommand , ctx : "InteractionContext" ) -> Any :
18952054 """Overrideable method that executes slash commands, can be used to wrap callback execution"""
18962055 return await command (ctx , ** ctx .kwargs )
@@ -2046,6 +2205,111 @@ async def __dispatch_interaction(
20462205 if completion_callback :
20472206 self .dispatch (completion_callback (ctx = ctx ))
20482207
2208+ async def generate_prefixes (self , client : Self , msg : Message ) -> str | list [str ]:
2209+ """
2210+ Generates a list of prefixes a prefixed command can have based on the client and message.
2211+
2212+ This can be overwritten by passing a function to generate_prefixes on initialization.
2213+
2214+ Args:
2215+ client: The client instance.
2216+ msg: The message sent.
2217+
2218+ Returns:
2219+ The prefix(es) to check for.
2220+
2221+ """
2222+ return self .default_prefix
2223+
2224+ def get_prefixed_command (self , name : str ) -> Optional [PrefixedCommand ]:
2225+ """
2226+ Gets a prefixed command by the name/alias specified.
2227+
2228+ This function is able to resolve subcommands - fully qualified names can be used.
2229+ For example, passing in ``foo bar`` would get the subcommand ``bar`` from the
2230+ command ``foo``.
2231+
2232+ Args:
2233+ name: The name of the command to search for.
2234+
2235+ Returns:
2236+ The command object, if found.
2237+
2238+ """
2239+ if " " not in name :
2240+ return self .prefixed_commands .get (name )
2241+
2242+ names = name .split ()
2243+ if not names :
2244+ return None
2245+
2246+ cmd = self .prefixed_commands .get (names [0 ])
2247+ if not cmd :
2248+ return cmd
2249+
2250+ for name in names [1 :]:
2251+ try :
2252+ cmd = cmd .subcommands [name ]
2253+ except (AttributeError , KeyError ):
2254+ return None
2255+
2256+ return cmd
2257+
2258+ def _remove_cmd_and_aliases (self , name : str ) -> None :
2259+ if cmd := self .prefixed_commands .pop (name , None ):
2260+ if cmd .extension :
2261+ with contextlib .suppress (ValueError ):
2262+ cmd .extension ._commands .remove (cmd )
2263+
2264+ for alias in cmd .aliases :
2265+ self .prefixed_commands .pop (alias , None )
2266+
2267+ def remove_prefixed_command (self , name : str , delete_parent_if_empty : bool = False ) -> Optional [PrefixedCommand ]:
2268+ """
2269+ Removes a prefixed command if it exists.
2270+
2271+ If an alias is specified, only the alias will be removed.
2272+ This function is able to resolve subcommands - fully qualified names can be used.
2273+ For example, passing in ``foo bar`` would delete the subcommand ``bar``
2274+ from the command ``foo``.
2275+
2276+ Args:
2277+ name: The command to remove.
2278+ delete_parent_if_empty: Should the parent command be deleted if it \
2279+ ends up having no subcommands after deleting the command specified? \
2280+ Defaults to `False`.
2281+
2282+ Returns:
2283+ The command that was removed, if one was. If the command was not found,
2284+ this function returns `None`.
2285+
2286+ """
2287+ command = self .get_prefixed_command (name )
2288+
2289+ if command is None :
2290+ return None
2291+
2292+ if name in command .aliases :
2293+ command .aliases .remove (name )
2294+ return command
2295+
2296+ if command .parent :
2297+ command .parent .remove_command (command .name )
2298+ else :
2299+ self ._remove_cmd_and_aliases (command .name )
2300+
2301+ if delete_parent_if_empty :
2302+ while command .parent is not None and not command .parent .subcommands :
2303+ if command .parent .parent :
2304+ _new_cmd = command .parent
2305+ command .parent .parent .remove_command (command .parent .name )
2306+ command = _new_cmd
2307+ else :
2308+ self ._remove_cmd_and_aliases (command .parent .name )
2309+ break
2310+
2311+ return command
2312+
20492313 @Listener .create ("disconnect" , is_default_listener = True )
20502314 async def _disconnect (self ) -> None :
20512315 self ._ready .clear ()
0 commit comments