feat: persist views (#120)
Some checks failed
Deploy MkDocs / deploy (push) Has been cancelled

This commit is contained in:
Slipstream 2025-06-15 20:39:12 -06:00 committed by GitHub
parent 80f64c1f73
commit aa55aa1d4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 76 additions and 17 deletions

View File

@ -2,8 +2,11 @@
The main Client class for interacting with the Discord API. The main Client class for interacting with the Discord API.
""" """
import asyncio import asyncio
import signal import signal
import json
import os
import importlib
from typing import ( from typing import (
Optional, Optional,
Callable, Callable,
@ -16,7 +19,9 @@ from typing import (
Dict, Dict,
cast, cast,
) )
from types import ModuleType from types import ModuleType
PERSISTENT_VIEWS_FILE = "persistent_views.json"
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -77,7 +82,7 @@ def _update_list(lst: List[Any], item: Any) -> None:
lst.append(item) lst.append(item)
class Client: class Client:
""" """
Represents a client connection that connects to Discord. Represents a client connection that connects to Discord.
This class is used to interact with the Discord WebSocket and API. This class is used to interact with the Discord WebSocket and API.
@ -193,7 +198,10 @@ class Client:
self._views: Dict[Snowflake, "View"] = {} self._views: Dict[Snowflake, "View"] = {}
self._persistent_views: Dict[str, "View"] = {} self._persistent_views: Dict[str, "View"] = {}
self._voice_clients: Dict[Snowflake, VoiceClient] = {} 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 # Default whether replies mention the user
self.mention_replies: bool = mention_replies self.mention_replies: bool = mention_replies
@ -210,13 +218,46 @@ class Client:
self.loop.add_signal_handler( self.loop.add_signal_handler(
signal.SIGTERM, lambda: self.loop.create_task(self.close()) signal.SIGTERM, lambda: self.loop.create_task(self.close())
) )
except NotImplementedError: except NotImplementedError:
# add_signal_handler is not available on all platforms (e.g., Windows default event loop policy) # 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. # Users on these platforms would need to handle shutdown differently.
print( print(
"Warning: Signal handlers for SIGINT/SIGTERM could not be added. " "Warning: Signal handlers for SIGINT/SIGTERM could not be added. "
"Graceful shutdown via signals might not work as expected on this platform." "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): async def _initialize_gateway(self):
"""Initializes the GatewayClient if it doesn't exist.""" """Initializes the GatewayClient if it doesn't exist."""
@ -1707,11 +1748,13 @@ class Client:
for item in view.children: for item in view.children:
if item.custom_id: # Ensure custom_id is not None if item.custom_id: # Ensure custom_id is not None
if item.custom_id in self._persistent_views: if item.custom_id in self._persistent_views:
raise ValueError( raise ValueError(
f"A component with custom_id '{item.custom_id}' is already registered." f"A component with custom_id '{item.custom_id}' is already registered."
) )
self._persistent_views[item.custom_id] = view self._persistent_views[item.custom_id] = view
self._save_persistent_views()
# --- Application Command Methods --- # --- Application Command Methods ---
async def process_interaction(self, interaction: Interaction) -> None: async def process_interaction(self, interaction: Interaction) -> None:

View File

@ -157,6 +157,22 @@ container = Container(
A container can itself contain layout and content components, letting you build complex messages. 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 ## Next Steps
- [Slash Commands](slash_commands.md) - [Slash Commands](slash_commands.md)