diff --git a/disagreement/client.py b/disagreement/client.py index b335d8a..b474477 100644 --- a/disagreement/client.py +++ b/disagreement/client.py @@ -2,8 +2,11 @@ The main Client class for interacting with the Discord API. """ -import asyncio -import signal +import asyncio +import signal +import json +import os +import importlib from typing import ( Optional, Callable, @@ -16,7 +19,9 @@ from typing import ( Dict, cast, ) -from types import ModuleType +from types import ModuleType + +PERSISTENT_VIEWS_FILE = "persistent_views.json" from datetime import datetime, timedelta @@ -77,7 +82,7 @@ def _update_list(lst: List[Any], item: Any) -> None: lst.append(item) -class Client: +class Client: """ Represents a client connection that connects to Discord. This class is used to interact with the Discord WebSocket and API. @@ -193,7 +198,10 @@ class Client: self._views: Dict[Snowflake, "View"] = {} self._persistent_views: Dict[str, "View"] = {} self._voice_clients: Dict[Snowflake, VoiceClient] = {} - self._webhooks: Dict[Snowflake, "Webhook"] = {} + self._webhooks: Dict[Snowflake, "Webhook"] = {} + + # Load persistent views stored on disk + self._load_persistent_views() # Default whether replies mention the user self.mention_replies: bool = mention_replies @@ -210,13 +218,46 @@ class Client: self.loop.add_signal_handler( signal.SIGTERM, lambda: self.loop.create_task(self.close()) ) - except NotImplementedError: - # add_signal_handler is not available on all platforms (e.g., Windows default event loop policy) - # Users on these platforms would need to handle shutdown differently. - print( - "Warning: Signal handlers for SIGINT/SIGTERM could not be added. " - "Graceful shutdown via signals might not work as expected on this platform." - ) + except NotImplementedError: + # add_signal_handler is not available on all platforms (e.g., Windows default event loop policy) + # Users on these platforms would need to handle shutdown differently. + print( + "Warning: Signal handlers for SIGINT/SIGTERM could not be added. " + "Graceful shutdown via signals might not work as expected on this platform." + ) + + def _load_persistent_views(self) -> None: + """Load registered persistent views from disk.""" + if not os.path.isfile(PERSISTENT_VIEWS_FILE): + return + try: + with open(PERSISTENT_VIEWS_FILE, "r") as fp: + mapping = json.load(fp) + except Exception as e: # pragma: no cover - best effort load + print(f"Failed to load persistent views: {e}") + return + + for custom_id, path in mapping.items(): + try: + module_name, class_name = path.rsplit(".", 1) + module = importlib.import_module(module_name) + cls = getattr(module, class_name) + view = cls() + self._persistent_views[custom_id] = view + except Exception as e: # pragma: no cover - best effort load + print(f"Failed to initialize persistent view {path}: {e}") + + def _save_persistent_views(self) -> None: + """Persist registered views to disk.""" + data = {} + for custom_id, view in self._persistent_views.items(): + cls = view.__class__ + data[custom_id] = f"{cls.__module__}.{cls.__name__}" + try: + with open(PERSISTENT_VIEWS_FILE, "w") as fp: + json.dump(data, fp) + except Exception as e: # pragma: no cover - best effort save + print(f"Failed to save persistent views: {e}") async def _initialize_gateway(self): """Initializes the GatewayClient if it doesn't exist.""" @@ -1707,11 +1748,13 @@ class Client: for item in view.children: if item.custom_id: # Ensure custom_id is not None - if item.custom_id in self._persistent_views: - raise ValueError( - f"A component with custom_id '{item.custom_id}' is already registered." - ) - self._persistent_views[item.custom_id] = view + if item.custom_id in self._persistent_views: + raise ValueError( + f"A component with custom_id '{item.custom_id}' is already registered." + ) + self._persistent_views[item.custom_id] = view + + self._save_persistent_views() # --- Application Command Methods --- async def process_interaction(self, interaction: Interaction) -> None: diff --git a/docs/using_components.md b/docs/using_components.md index e987217..b7736ca 100644 --- a/docs/using_components.md +++ b/docs/using_components.md @@ -157,6 +157,22 @@ container = Container( A container can itself contain layout and content components, letting you build complex messages. +## Persistent Views + +Views with ``timeout=None`` are persistent. Their ``custom_id`` components are saved to ``persistent_views.json`` so they survive bot restarts. + +```python +class MyView(View): + @button(label="Press", custom_id="press") + async def handle(self, view, inter): + await inter.respond("Pressed!") + +client.add_persistent_view(MyView()) +``` + +When the client starts, it loads this file and registers each view again. Remove +the file to clear stored views. + ## Next Steps - [Slash Commands](slash_commands.md)