2323 Type ,
2424 Union ,
2525 Awaitable ,
26+ Tuple ,
2627)
2728
2829import interactions .api .events as events
2930import interactions .client .const as constants
30- from interactions .models .internal .callback import CallbackObject
3131from interactions .api .events import BaseEvent , RawGatewayEvent , processors
3232from interactions .api .events .internal import CallbackAdded
3333from interactions .api .gateway .gateway import GatewayClient
9494from interactions .models .internal .active_voice_state import ActiveVoiceState
9595from interactions .models .internal .application_commands import ContextMenu , ModalCommand , GlobalAutoComplete
9696from interactions .models .internal .auto_defer import AutoDefer
97+ from interactions .models .internal .callback import CallbackObject
9798from interactions .models .internal .command import BaseCommand
9899from interactions .models .internal .context import (
99100 BaseContext ,
@@ -420,7 +421,8 @@ def __init__(
420421 async def __aenter__ (self ) -> "Client" :
421422 if not self .token :
422423 raise ValueError (
423- "Token not found - to use the bot in a context manager, you must pass the token in the Client constructor."
424+ "Token not found - to use the bot in a context manager, you must pass the token in the Client"
425+ " constructor."
424426 )
425427 await self .login (self .token )
426428 return self
@@ -534,7 +536,8 @@ def _sanity_check(self) -> None:
534536
535537 if self .del_unused_app_cmd :
536538 self .logger .warning (
537- "As `delete_unused_application_cmds` is enabled, the client must cache all guilds app-commands, this could take a while."
539+ "As `delete_unused_application_cmds` is enabled, the client must cache all guilds app-commands, this"
540+ " could take a while."
538541 )
539542
540543 if Intents .GUILDS not in self ._connection_state .intents :
@@ -646,16 +649,17 @@ async def on_command_error(self, event: events.CommandError) -> None:
646649 if isinstance (event .error , errors .CommandOnCooldown ):
647650 await event .ctx .send (
648651 embeds = Embed (
649- description = f"This command is on cooldown!\n "
650- f"Please try again in { int (event .error .cooldown .get_cooldown_time ())} seconds" ,
652+ description = (
653+ "This command is on cooldown!\n "
654+ f"Please try again in { int (event .error .cooldown .get_cooldown_time ())} seconds"
655+ ),
651656 color = BrandColors .FUCHSIA ,
652657 )
653658 )
654659 elif isinstance (event .error , errors .MaxConcurrencyReached ):
655660 await event .ctx .send (
656661 embeds = Embed (
657- description = "This command has reached its maximum concurrent usage!\n "
658- "Please try again shortly." ,
662+ description = "This command has reached its maximum concurrent usage!\n Please try again shortly." ,
659663 color = BrandColors .FUCHSIA ,
660664 )
661665 )
@@ -757,7 +761,8 @@ async def on_autocomplete_completion(self, event: events.AutocompleteCompletion)
757761 """
758762 symbol = "$"
759763 self .logger .info (
760- f"Autocomplete Called: { symbol } { event .ctx .invoke_target } with { event .ctx .focussed_option = } | { event .ctx .kwargs = } "
764+ f"Autocomplete Called: { symbol } { event .ctx .invoke_target } with { event .ctx .focussed_option = } |"
765+ f" { event .ctx .kwargs = } "
761766 )
762767
763768 @Listener .create (is_default_listener = True )
@@ -1176,7 +1181,8 @@ def add_listener(self, listener: Listener) -> None:
11761181 """
11771182 if listener .event == "event" :
11781183 self .logger .critical (
1179- f"Subscribing to `{ listener .event } ` - Meta Events are very expensive; remember to remove it before releasing your bot"
1184+ f"Subscribing to `{ listener .event } ` - Meta Events are very expensive; remember to remove it before"
1185+ " releasing your bot"
11801186 )
11811187
11821188 if not listener .is_default_listener :
@@ -1187,7 +1193,8 @@ def add_listener(self, listener: Listener) -> None:
11871193 if required_intents := _INTENT_EVENTS .get (event_class ):
11881194 if all (required_intent not in self .intents for required_intent in required_intents ):
11891195 self .logger .warning (
1190- f"Event `{ listener .event } ` will not work since the required intent is not set -> Requires any of: `{ required_intents } `"
1196+ f"Event `{ listener .event } ` will not work since the required intent is not set -> Requires"
1197+ f" any of: `{ required_intents } `"
11911198 )
11921199
11931200 # prevent the same callback being added twice
@@ -1420,8 +1427,7 @@ async def wrap(*args, **kwargs) -> Absent[List[Dict]]:
14201427 )
14211428 continue
14221429 found .add (cmd_name )
1423- self ._interaction_lookup [cmd .resolved_name ] = cmd
1424- cmd .cmd_id [scope ] = int (cmd_data ["id" ])
1430+ self .update_command_cache (scope , cmd .resolved_name , cmd_data ["id" ])
14251431
14261432 if warn_missing :
14271433 for cmd_data in remote_cmds .values ():
@@ -1440,80 +1446,143 @@ async def synchronise_interactions(
14401446 Synchronise registered interactions with discord.
14411447
14421448 Args:
1443- scopes: Optionally specify which scopes are to be synced
1444- delete_commands: Override the client setting and delete commands
1449+ scopes: Optionally specify which scopes are to be synced.
1450+ delete_commands: Override the client setting and delete commands.
1451+
1452+ Returns:
1453+ None
1454+
1455+ Raises:
1456+ InteractionMissingAccess: If bot is lacking the necessary access.
1457+ Exception: If there is an error during the synchronization process.
14451458 """
14461459 s = time .perf_counter ()
14471460 _delete_cmds = self .del_unused_app_cmd if delete_commands is MISSING else delete_commands
14481461 await self ._cache_interactions ()
14491462
1463+ cmd_scopes = self ._get_sync_scopes (scopes )
1464+ local_cmds_json = application_commands_to_dict (self .interactions_by_scope , self )
1465+
1466+ await asyncio .gather (* [self .sync_scope (scope , _delete_cmds , local_cmds_json ) for scope in cmd_scopes ])
1467+
1468+ t = time .perf_counter () - s
1469+ self .logger .debug (f"Sync of { len (cmd_scopes )} scopes took { t } seconds" )
1470+
1471+ def _get_sync_scopes (self , scopes : Sequence ["Snowflake_Type" ]) -> List ["Snowflake_Type" ]:
1472+ """
1473+ Determine which scopes to sync.
1474+
1475+ Args:
1476+ scopes: The scopes to sync.
1477+
1478+ Returns:
1479+ The scopes to sync.
1480+ """
14501481 if scopes is not MISSING :
1451- cmd_scopes = scopes
1452- elif self .del_unused_app_cmd :
1453- # if we're deleting unused commands, we check all scopes
1454- cmd_scopes = [to_snowflake (g_id ) for g_id in self ._user ._guild_ids ] + [GLOBAL_SCOPE ]
1455- else :
1456- # if we're not deleting, just check the scopes we have cmds registered in
1457- cmd_scopes = list (set (self .interactions_by_scope ) | {GLOBAL_SCOPE })
1482+ return scopes
1483+ if self .del_unused_app_cmd :
1484+ return [to_snowflake (g_id ) for g_id in self ._user ._guild_ids ] + [GLOBAL_SCOPE ]
1485+ return list (set (self .interactions_by_scope ) | {GLOBAL_SCOPE })
14581486
1459- local_cmds_json = application_commands_to_dict (self .interactions_by_scope , self )
1487+ async def sync_scope (
1488+ self ,
1489+ cmd_scope : "Snowflake_Type" ,
1490+ delete_cmds : bool ,
1491+ local_cmds_json : Dict ["Snowflake_Type" , List [Dict [str , Any ]]],
1492+ ) -> None :
1493+ """
1494+ Sync a single scope.
14601495
1461- async def sync_scope (cmd_scope ) -> None :
1462- sync_needed_flag = False # a flag to force this scope to synchronise
1463- sync_payload = [] # the payload to be pushed to discord
1496+ Args:
1497+ cmd_scope: The scope to sync.
1498+ delete_cmds: Whether to delete commands.
1499+ local_cmds_json: The local commands in json format.
1500+ """
1501+ sync_needed_flag = False
1502+ sync_payload = []
14641503
1465- try :
1466- try :
1467- remote_commands = await self .http .get_application_commands (self .app .id , cmd_scope )
1468- except Forbidden :
1469- self .logger .warning (f"Bot is lacking `application.commands` scope in { cmd_scope } !" )
1470- return
1504+ try :
1505+ remote_commands = await self .get_remote_commands (cmd_scope )
1506+ sync_payload , sync_needed_flag = self ._build_sync_payload (
1507+ remote_commands , cmd_scope , local_cmds_json , delete_cmds
1508+ )
14711509
1472- for local_cmd in self .interactions_by_scope .get (cmd_scope , {}).values ():
1473- # get remote equivalent of this command
1474- remote_cmd_json = next (
1475- (v for v in remote_commands if int (v ["id" ]) == local_cmd .cmd_id .get (cmd_scope )),
1476- None ,
1477- )
1478- # get json representation of this command
1479- local_cmd_json = next ((c for c in local_cmds_json [cmd_scope ] if c ["name" ] == str (local_cmd .name )))
1480-
1481- # this works by adding any command we *want* on Discord, to a payload, and synchronising that
1482- # this allows us to delete unused commands, add new commands, or do nothing in 1 or less API calls
1483-
1484- if sync_needed (local_cmd_json , remote_cmd_json ):
1485- # determine if the local and remote commands are out-of-sync
1486- sync_needed_flag = True
1487- sync_payload .append (local_cmd_json )
1488- elif not _delete_cmds and remote_cmd_json :
1489- _remote_payload = {
1490- k : v for k , v in remote_cmd_json .items () if k not in ("id" , "application_id" , "version" )
1491- }
1492- sync_payload .append (_remote_payload )
1493- elif _delete_cmds :
1494- sync_payload .append (local_cmd_json )
1495-
1496- sync_payload = [FastJson .loads (_dump ) for _dump in {FastJson .dumps (_cmd ) for _cmd in sync_payload }]
1497-
1498- if sync_needed_flag or (_delete_cmds and len (sync_payload ) < len (remote_commands )):
1499- # synchronise commands if flag is set, or commands are to be deleted
1500- self .logger .info (f"Overwriting { cmd_scope } with { len (sync_payload )} application commands" )
1501- sync_response : list [dict ] = await self .http .overwrite_application_commands (
1502- self .app .id , sync_payload , cmd_scope
1503- )
1504- self ._cache_sync_response (sync_response , cmd_scope )
1505- else :
1506- self .logger .debug (f"{ cmd_scope } is already up-to-date with { len (remote_commands )} commands." )
1510+ if sync_needed_flag or (delete_cmds and len (sync_payload ) < len (remote_commands )):
1511+ await self ._sync_commands_with_discord (sync_payload , cmd_scope )
1512+ else :
1513+ self .logger .debug (f"{ cmd_scope } is already up-to-date with { len (remote_commands )} commands." )
15071514
1508- except Forbidden as e :
1509- raise InteractionMissingAccess (cmd_scope ) from e
1510- except HTTPException as e :
1511- self ._raise_sync_exception (e , local_cmds_json , cmd_scope )
1515+ except Forbidden as e :
1516+ raise InteractionMissingAccess (cmd_scope ) from e
1517+ except HTTPException as e :
1518+ self ._raise_sync_exception (e , local_cmds_json , cmd_scope )
15121519
1513- await asyncio .gather (* [sync_scope (scope ) for scope in cmd_scopes ])
1520+ async def get_remote_commands (self , cmd_scope : "Snowflake_Type" ) -> List [Dict [str , Any ]]:
1521+ """
1522+ Get the remote commands for a scope.
15141523
1515- t = time .perf_counter () - s
1516- self .logger .debug (f"Sync of { len (cmd_scopes )} scopes took { t } seconds" )
1524+ Args:
1525+ cmd_scope: The scope to get the commands for.
1526+ """
1527+ try :
1528+ return await self .http .get_application_commands (self .app .id , cmd_scope )
1529+ except Forbidden :
1530+ self .logger .warning (f"Bot is lacking `application.commands` scope in { cmd_scope } !" )
1531+ return []
1532+
1533+ def _build_sync_payload (
1534+ self ,
1535+ remote_commands : List [Dict [str , Any ]],
1536+ cmd_scope : "Snowflake_Type" ,
1537+ local_cmds_json : Dict ["Snowflake_Type" , List [Dict [str , Any ]]],
1538+ delete_cmds : bool ,
1539+ ) -> Tuple [List [Dict [str , Any ]], bool ]:
1540+ """
1541+ Build the sync payload for a single scope.
1542+
1543+ Args:
1544+ remote_commands: The remote commands.
1545+ cmd_scope: The scope to sync.
1546+ local_cmds_json: The local commands in json format.
1547+ delete_cmds: Whether to delete commands.
1548+ """
1549+ sync_payload = []
1550+ sync_needed_flag = False
1551+
1552+ for local_cmd in self .interactions_by_scope .get (cmd_scope , {}).values ():
1553+ remote_cmd_json = next (
1554+ (v for v in remote_commands if int (v ["id" ]) == local_cmd .cmd_id .get (cmd_scope )),
1555+ None ,
1556+ )
1557+ local_cmd_json = next ((c for c in local_cmds_json [cmd_scope ] if c ["name" ] == str (local_cmd .name )))
1558+
1559+ if sync_needed (local_cmd_json , remote_cmd_json ):
1560+ sync_needed_flag = True
1561+ sync_payload .append (local_cmd_json )
1562+ elif not delete_cmds and remote_cmd_json :
1563+ _remote_payload = {
1564+ k : v for k , v in remote_cmd_json .items () if k not in ("id" , "application_id" , "version" )
1565+ }
1566+ sync_payload .append (_remote_payload )
1567+ elif delete_cmds :
1568+ sync_payload .append (local_cmd_json )
1569+
1570+ sync_payload = [FastJson .loads (_dump ) for _dump in {FastJson .dumps (_cmd ) for _cmd in sync_payload }]
1571+ return sync_payload , sync_needed_flag
1572+
1573+ async def _sync_commands_with_discord (
1574+ self , sync_payload : List [Dict [str , Any ]], cmd_scope : "Snowflake_Type"
1575+ ) -> None :
1576+ """
1577+ Sync the commands with discord.
1578+
1579+ Args:
1580+ sync_payload: The sync payload.
1581+ cmd_scope: The scope to sync.
1582+ """
1583+ self .logger .info (f"Overwriting { cmd_scope } with { len (sync_payload )} application commands" )
1584+ sync_response : list [dict ] = await self .http .overwrite_application_commands (self .app .id , sync_payload , cmd_scope )
1585+ self ._cache_sync_response (sync_response , cmd_scope )
15171586
15181587 def get_application_cmd_by_id (
15191588 self , cmd_id : "Snowflake_Type" , * , scope : "Snowflake_Type" = None
@@ -1556,29 +1625,38 @@ def _raise_sync_exception(self, e: HTTPException, cmds_json: dict, cmd_scope: "S
15561625 def _cache_sync_response (self , sync_response : list [dict ], scope : "Snowflake_Type" ) -> None :
15571626 for cmd_data in sync_response :
15581627 command_id = Snowflake (cmd_data ["id" ])
1559- command_name = cmd_data ["name" ]
1560-
1561- if any (
1562- option ["type" ] in (OptionType .SUB_COMMAND , OptionType .SUB_COMMAND_GROUP )
1563- for option in cmd_data .get ("options" , [])
1564- ):
1565- for option in cmd_data .get ("options" , []):
1566- if option ["type" ] in (OptionType .SUB_COMMAND , OptionType .SUB_COMMAND_GROUP ):
1567- command_name = f"{ command_name } { option ['name' ]} "
1568- if option ["type" ] == OptionType .SUB_COMMAND_GROUP :
1569- for _sc in option .get ("options" , []):
1570- command_name = f"{ command_name } { _sc ['name' ]} "
1571- if command := self .interactions_by_scope [scope ].get (command_name ):
1572- command .cmd_id [scope ] = command_id
1573- self ._interaction_lookup [command .resolved_name ] = command
1574- elif command := self .interactions_by_scope [scope ].get (command_name ):
1575- command .cmd_id [scope ] = command_id
1576- self ._interaction_lookup [command .resolved_name ] = command
1577- continue
1578- elif command := self .interactions_by_scope [scope ].get (command_name ):
1579- command .cmd_id [scope ] = command_id
1580- self ._interaction_lookup [command .resolved_name ] = command
1581- continue
1628+ tier_0_name = cmd_data ["name" ]
1629+ options = cmd_data .get ("options" , [])
1630+
1631+ if any (option ["type" ] in (OptionType .SUB_COMMAND , OptionType .SUB_COMMAND_GROUP ) for option in options ):
1632+ for option in options :
1633+ option_type = option ["type" ]
1634+
1635+ if option_type in (OptionType .SUB_COMMAND , OptionType .SUB_COMMAND_GROUP ):
1636+ tier_2_name = f"{ tier_0_name } { option ['name' ]} "
1637+
1638+ if option_type == OptionType .SUB_COMMAND_GROUP :
1639+ for sub_option in option .get ("options" , []):
1640+ tier_3_name = f"{ tier_2_name } { sub_option ['name' ]} "
1641+ self .update_command_cache (scope , tier_3_name , command_id )
1642+ else :
1643+ self .update_command_cache (scope , tier_2_name , command_id )
1644+
1645+ else :
1646+ self .update_command_cache (scope , tier_0_name , command_id )
1647+
1648+ def update_command_cache (self , scope : "Snowflake_Type" , command_name : str , command_id : "Snowflake" ) -> None :
1649+ """
1650+ Update the internal cache with a command ID.
1651+
1652+ Args:
1653+ scope: The scope of the command to update
1654+ command_name: The name of the command
1655+ command_id: The ID of the command
1656+ """
1657+ if command := self .interactions_by_scope [scope ].get (command_name ):
1658+ command .cmd_id [scope ] = command_id
1659+ self ._interaction_lookup [command .resolved_name ] = command
15821660
15831661 async def get_context (self , data : dict ) -> InteractionContext :
15841662 match data ["type" ]:
0 commit comments