Merge pull request #47 from Slipstreamm/codex/add-json-persistence-for-command-ids

Merge PR #47
This commit is contained in:
Slipstream 2025-06-10 20:49:48 -06:00 committed by GitHub
commit 463ad26217
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 112 additions and 30 deletions

View File

@ -1,7 +1,9 @@
# disagreement/ext/app_commands/handler.py # disagreement/ext/app_commands/handler.py
import inspect import inspect
import json
import logging import logging
import os
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Dict, Dict,
@ -67,6 +69,8 @@ if not TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
COMMANDS_CACHE_FILE = ".disagreement_commands.json"
class AppCommandHandler: class AppCommandHandler:
""" """
@ -84,6 +88,33 @@ class AppCommandHandler:
self._app_command_groups: Dict[str, AppCommandGroup] = {} self._app_command_groups: Dict[str, AppCommandGroup] = {}
self._converter_registry: Dict[type, type] = {} self._converter_registry: Dict[type, type] = {}
def _load_cached_ids(self) -> Dict[str, Dict[str, str]]:
try:
with open(COMMANDS_CACHE_FILE, "r", encoding="utf-8") as fp:
return json.load(fp)
except FileNotFoundError:
return {}
except json.JSONDecodeError:
logger.warning("Invalid command cache file. Ignoring.")
return {}
def _save_cached_ids(self, data: Dict[str, Dict[str, str]]) -> None:
try:
with open(COMMANDS_CACHE_FILE, "w", encoding="utf-8") as fp:
json.dump(data, fp, indent=2)
except Exception as e: # pragma: no cover - logging only
logger.error("Failed to write command cache: %s", e)
def clear_stored_registrations(self) -> None:
"""Remove persisted command registration data."""
if os.path.exists(COMMANDS_CACHE_FILE):
os.remove(COMMANDS_CACHE_FILE)
def migrate_stored_registrations(self, new_path: str) -> None:
"""Move stored registrations to ``new_path``."""
if os.path.exists(COMMANDS_CACHE_FILE):
os.replace(COMMANDS_CACHE_FILE, new_path)
def add_command(self, command: Union["AppCommand", "AppCommandGroup"]) -> None: def add_command(self, command: Union["AppCommand", "AppCommandGroup"]) -> None:
"""Adds an application command or a command group to the handler.""" """Adds an application command or a command group to the handler."""
if isinstance(command, AppCommandGroup): if isinstance(command, AppCommandGroup):
@ -564,11 +595,13 @@ class AppCommandHandler:
Synchronizes (registers/updates) all application commands with Discord. Synchronizes (registers/updates) all application commands with Discord.
If guild_id is provided, syncs commands for that guild. Otherwise, syncs global commands. If guild_id is provided, syncs commands for that guild. Otherwise, syncs global commands.
""" """
commands_to_sync: List[Dict[str, Any]] = [] cache = self._load_cached_ids()
scope_key = str(guild_id) if guild_id else "global"
stored = cache.get(scope_key, {})
current_payloads: Dict[str, Dict[str, Any]] = {}
# Collect commands based on scope (global or specific guild) # Collect commands based on scope (global or specific guild)
# This needs to be more sophisticated to handle guild_ids on commands/groups
source_commands = ( source_commands = (
list(self._slash_commands.values()) list(self._slash_commands.values())
+ list(self._user_commands.values()) + list(self._user_commands.values())
@ -577,26 +610,22 @@ class AppCommandHandler:
) )
for cmd_or_group in source_commands: for cmd_or_group in source_commands:
# Determine if this command/group should be synced for the current scope
is_guild_specific_command = ( is_guild_specific_command = (
cmd_or_group.guild_ids is not None and len(cmd_or_group.guild_ids) > 0 cmd_or_group.guild_ids is not None and len(cmd_or_group.guild_ids) > 0
) )
if guild_id: # Syncing for a specific guild if guild_id:
# Skip if not a guild-specific command OR if it's for a different guild
if not is_guild_specific_command or ( if not is_guild_specific_command or (
cmd_or_group.guild_ids is not None cmd_or_group.guild_ids is not None
and guild_id not in cmd_or_group.guild_ids and guild_id not in cmd_or_group.guild_ids
): ):
continue continue
else: # Syncing global commands else:
if is_guild_specific_command: if is_guild_specific_command:
continue # Skip guild-specific commands when syncing global continue
# Use the to_dict() method from AppCommand or AppCommandGroup
try: try:
payload = cmd_or_group.to_dict() current_payloads[cmd_or_group.name] = cmd_or_group.to_dict()
commands_to_sync.append(payload)
except AttributeError: except AttributeError:
logger.warning( logger.warning(
"Command or group '%s' does not have a to_dict() method. Skipping.", "Command or group '%s' does not have a to_dict() method. Skipping.",
@ -609,32 +638,74 @@ class AppCommandHandler:
e, e,
) )
if not commands_to_sync: if not current_payloads:
logger.info( logger.info(
"No commands to sync for %s scope.", "No commands to sync for %s scope.",
f"guild {guild_id}" if guild_id else "global", f"guild {guild_id}" if guild_id else "global",
) )
return return
names_current = set(current_payloads)
names_stored = set(stored)
to_delete = names_stored - names_current
to_create = names_current - names_stored
to_update = names_current & names_stored
if not to_delete and not to_create and not to_update:
logger.info(
"Application commands already up to date for %s scope.", scope_key
)
return
try: try:
if guild_id: for name in to_delete:
logger.info( cmd_id = stored[name]
"Syncing %s commands for guild %s...", if guild_id:
len(commands_to_sync), await self.client._http.delete_guild_application_command(
guild_id, application_id, guild_id, cmd_id
) )
await self.client._http.bulk_overwrite_guild_application_commands( else:
application_id, guild_id, commands_to_sync await self.client._http.delete_global_application_command(
) application_id, cmd_id
else: )
logger.info(
"Syncing %s global commands...", new_ids: Dict[str, str] = {}
len(commands_to_sync), for name in to_create:
) payload = current_payloads[name]
await self.client._http.bulk_overwrite_global_application_commands( if guild_id:
application_id, commands_to_sync result = await self.client._http.create_guild_application_command(
) application_id, guild_id, payload
)
else:
result = await self.client._http.create_global_application_command(
application_id, payload
)
if result.id:
new_ids[name] = str(result.id)
for name in to_update:
payload = current_payloads[name]
cmd_id = stored[name]
if guild_id:
await self.client._http.edit_guild_application_command(
application_id, guild_id, cmd_id, payload
)
else:
await self.client._http.edit_global_application_command(
application_id, cmd_id, payload
)
new_ids[name] = cmd_id
final_ids: Dict[str, str] = {}
for name in names_current:
if name in new_ids:
final_ids[name] = new_ids[name]
else:
final_ids[name] = stored[name]
cache[scope_key] = final_ids
self._save_cached_ids(cache)
logger.info("Command sync successful.") logger.info("Command sync successful.")
except Exception as e: except Exception as e:
logger.error("Error syncing application commands: %s", e) logger.error("Error syncing application commands: %s", e)
# Consider re-raising or specific error handling

View File

@ -20,3 +20,14 @@ Use `AppCommandGroup` to group related commands. See the [components guide](usin
- [Caching](caching.md) - [Caching](caching.md)
- [Voice Features](voice_features.md) - [Voice Features](voice_features.md)
## Command Persistence
`AppCommandHandler.sync_commands` can persist registered command IDs in
`.disagreement_commands.json`. When enabled, subsequent syncs compare the
stored IDs to the commands defined in code and only create, edit or delete
commands when changes are detected.
Call `AppCommandHandler.clear_stored_registrations()` if you need to wipe the
stored IDs or migrate them elsewhere with
`AppCommandHandler.migrate_stored_registrations()`.