23
23
Type ,
24
24
Union ,
25
25
Awaitable ,
26
+ Tuple ,
26
27
)
27
28
28
29
import interactions .api .events as events
29
30
import interactions .client .const as constants
30
- from interactions .models .internal .callback import CallbackObject
31
31
from interactions .api .events import BaseEvent , RawGatewayEvent , processors
32
32
from interactions .api .events .internal import CallbackAdded
33
33
from interactions .api .gateway .gateway import GatewayClient
94
94
from interactions .models .internal .active_voice_state import ActiveVoiceState
95
95
from interactions .models .internal .application_commands import ContextMenu , ModalCommand , GlobalAutoComplete
96
96
from interactions .models .internal .auto_defer import AutoDefer
97
+ from interactions .models .internal .callback import CallbackObject
97
98
from interactions .models .internal .command import BaseCommand
98
99
from interactions .models .internal .context import (
99
100
BaseContext ,
@@ -420,7 +421,8 @@ def __init__(
420
421
async def __aenter__ (self ) -> "Client" :
421
422
if not self .token :
422
423
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."
424
426
)
425
427
await self .login (self .token )
426
428
return self
@@ -534,7 +536,8 @@ def _sanity_check(self) -> None:
534
536
535
537
if self .del_unused_app_cmd :
536
538
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."
538
541
)
539
542
540
543
if Intents .GUILDS not in self ._connection_state .intents :
@@ -646,16 +649,17 @@ async def on_command_error(self, event: events.CommandError) -> None:
646
649
if isinstance (event .error , errors .CommandOnCooldown ):
647
650
await event .ctx .send (
648
651
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
+ ),
651
656
color = BrandColors .FUCHSIA ,
652
657
)
653
658
)
654
659
elif isinstance (event .error , errors .MaxConcurrencyReached ):
655
660
await event .ctx .send (
656
661
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." ,
659
663
color = BrandColors .FUCHSIA ,
660
664
)
661
665
)
@@ -757,7 +761,8 @@ async def on_autocomplete_completion(self, event: events.AutocompleteCompletion)
757
761
"""
758
762
symbol = "$"
759
763
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 = } "
761
766
)
762
767
763
768
@Listener .create (is_default_listener = True )
@@ -1176,7 +1181,8 @@ def add_listener(self, listener: Listener) -> None:
1176
1181
"""
1177
1182
if listener .event == "event" :
1178
1183
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"
1180
1186
)
1181
1187
1182
1188
if not listener .is_default_listener :
@@ -1187,7 +1193,8 @@ def add_listener(self, listener: Listener) -> None:
1187
1193
if required_intents := _INTENT_EVENTS .get (event_class ):
1188
1194
if all (required_intent not in self .intents for required_intent in required_intents ):
1189
1195
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 } `"
1191
1198
)
1192
1199
1193
1200
# prevent the same callback being added twice
@@ -1420,8 +1427,7 @@ async def wrap(*args, **kwargs) -> Absent[List[Dict]]:
1420
1427
)
1421
1428
continue
1422
1429
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" ])
1425
1431
1426
1432
if warn_missing :
1427
1433
for cmd_data in remote_cmds .values ():
@@ -1440,80 +1446,143 @@ async def synchronise_interactions(
1440
1446
Synchronise registered interactions with discord.
1441
1447
1442
1448
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.
1445
1458
"""
1446
1459
s = time .perf_counter ()
1447
1460
_delete_cmds = self .del_unused_app_cmd if delete_commands is MISSING else delete_commands
1448
1461
await self ._cache_interactions ()
1449
1462
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
+ """
1450
1481
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 })
1458
1486
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.
1460
1495
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 = []
1464
1503
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
+ )
1471
1509
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." )
1507
1514
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 )
1512
1519
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.
1514
1523
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 )
1517
1586
1518
1587
def get_application_cmd_by_id (
1519
1588
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
1556
1625
def _cache_sync_response (self , sync_response : list [dict ], scope : "Snowflake_Type" ) -> None :
1557
1626
for cmd_data in sync_response :
1558
1627
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
1582
1660
1583
1661
async def get_context (self , data : dict ) -> InteractionContext :
1584
1662
match data ["type" ]:
0 commit comments