Skip to content

Commit c987e31

Browse files
authored
feat: sync improvements (#1328)
* refactor: improve readability of sync logic * fix: improve initial sync caching * docs: add docstring to update_command_cache * feat: add command deletion to debug ext
1 parent 9e06828 commit c987e31

File tree

2 files changed

+240
-97
lines changed

2 files changed

+240
-97
lines changed

interactions/client/client.py

Lines changed: 174 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@
2323
Type,
2424
Union,
2525
Awaitable,
26+
Tuple,
2627
)
2728

2829
import interactions.api.events as events
2930
import interactions.client.const as constants
30-
from interactions.models.internal.callback import CallbackObject
3131
from interactions.api.events import BaseEvent, RawGatewayEvent, processors
3232
from interactions.api.events.internal import CallbackAdded
3333
from interactions.api.gateway.gateway import GatewayClient
@@ -94,6 +94,7 @@
9494
from interactions.models.internal.active_voice_state import ActiveVoiceState
9595
from interactions.models.internal.application_commands import ContextMenu, ModalCommand, GlobalAutoComplete
9696
from interactions.models.internal.auto_defer import AutoDefer
97+
from interactions.models.internal.callback import CallbackObject
9798
from interactions.models.internal.command import BaseCommand
9899
from 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!\nPlease 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

Comments
 (0)