from __future__ import annotations from typing import Any, TYPE_CHECKING from yuxi.utils.logging_config import logger if TYPE_CHECKING: from .client import UrbitClient _MIGRATABLE_KEYS = [ "dmAllowlist", "groupChannels", "autoDiscoverChannels", "ownerShip", "autoAcceptGroupInvites", "autoAcceptDmInvites", "groupInviteAllowlist", ] class SettingsStore: def __init__(self, client: UrbitClient, desk: str = "moltbot"): self._client = client self._desk = desk self._store: dict[str, Any] = {} self._file_config: dict[str, Any] = {} self._loaded = False self._version: int = 0 self._key_versions: dict[str, int] = {} async def load(self, file_config: dict[str, Any] | None = None) -> dict[str, Any]: if file_config: self._file_config = file_config try: from .scry import scry_settings store_data = await scry_settings(self._client, self._desk) if store_data and isinstance(store_data, dict): self._store = store_data self._version += 1 logger.info(f"[Urbit] Settings Store loaded from desk '{self._desk}' (v{self._version})") else: logger.debug(f"[Urbit] Settings Store empty or not found for desk '{self._desk}'") except Exception as e: logger.warning(f"[Urbit] Failed to load Settings Store: {e}") self._loaded = True return self._store def get(self, key: str, default: Any = None) -> Any: store_val = self._store.get(key) if store_val is not None: if isinstance(store_val, list) and len(store_val) == 0: return [] return store_val return self._file_config.get(key, default) def get_all(self) -> dict[str, Any]: merged = dict(self._file_config) for key, value in self._store.items(): if value is not None: merged[key] = value return merged def merge_key(self, key: str, value: Any, expected_version: int | None = None) -> bool: if expected_version is not None: current_version = self._key_versions.get(key, 0) if expected_version < current_version: logger.debug( f"[Urbit] Settings merge rejected for '{key}': version {expected_version} < {current_version}" ) return False store = self._store.copy() store[key] = value self._store = store self._key_versions[key] = self._key_versions.get(key, 0) + 1 return True def replace_all(self, data: dict[str, Any]) -> None: self._store = dict(data) self._version += 1 logger.debug(f"[Urbit] Settings Store replaced (v{self._version})") def update_key(self, key: str, value: Any) -> None: store = dict(self._store) store[key] = value self._store = store self._key_versions[key] = self._key_versions.get(key, 0) + 1 self._version += 1 async def reload(self) -> None: self._version += 1 await self.load(self._file_config) async def build_settings_migrations(self) -> list[dict[str, Any]]: migrations: list[dict[str, Any]] = [] for key in _MIGRATABLE_KEYS: if self.should_migrate_setting(key): migrations.append( { "bucket_key": "moltbot", "entry_key": key, "value": self._file_config.get(key), } ) return migrations def should_migrate_setting(self, key: str) -> bool: file_val = self._file_config.get(key) if file_val is None: return False store_val = self._store.get(key) return store_val is None async def poke_migration(self, bucket_key: str, entry_key: str, value: Any) -> bool: import json as _json try: value_str = _json.dumps(value) if not isinstance(value, str) else value payload = { "action": "poke", "ship": self._client.ship_name, "app": "settings", "mark": "settings-event", "json": { "put-entry": { "bucket-key": bucket_key, "entry-key": entry_key, "value": value_str, } }, } r = await self._client.put("/~/channel/settings-store", json=payload, timeout=10.0) r.raise_for_status() logger.info(f"[Urbit] Migrated setting: {entry_key} → Settings Store") return True except Exception as e: logger.warning(f"[Urbit] Migration failed for {entry_key}: {e}") return False @property def is_loaded(self) -> bool: return self._loaded @property def version(self) -> int: return self._version def parse_channel_rules(raw: Any) -> dict[str, Any] | None: if raw is None: return None if isinstance(raw, dict): return raw if isinstance(raw, str): import json as _json try: return _json.loads(raw) except _json.JSONDecodeError: logger.warning("[Urbit] parse_channel_rules: invalid JSON string") return None return None def parse_pending_approvals(raw: Any) -> list[dict[str, Any]] | None: if raw is None: return None if isinstance(raw, list): return raw if isinstance(raw, str): import json as _json try: return _json.loads(raw) except _json.JSONDecodeError: logger.warning("[Urbit] parse_pending_approvals: invalid JSON string") return None return None def serialize_to_json_value(value: Any) -> str: import json as _json return _json.dumps(value)