from __future__ import annotations import time import uuid from dataclasses import dataclass from typing import Any from yuxi.utils.logging_config import logger @dataclass class PendingApproval: id: str approval_type: str requester_ship: str channel_id: str message_content: str created_at: float status: str = "pending" def approve(self) -> None: self.status = "approved" def deny(self) -> None: self.status = "denied" class ApprovalSystem: def __init__(self, owner_ship: str = ""): self._owner_ship = owner_ship.lstrip("~") self._pending: dict[str, PendingApproval] = {} self._resolved: dict[str, PendingApproval] = {} self._blocked_ships: set[str] = set() @property def owner_ship(self) -> str: return self._owner_ship def create_pending( self, approval_type: str, requester_ship: str, channel_id: str, message_content: str, ) -> PendingApproval: approval = PendingApproval( id=f"{approval_type}-{int(time.time())}-{str(uuid.uuid4())[:8]}", approval_type=approval_type, requester_ship=requester_ship, channel_id=channel_id, message_content=message_content, created_at=time.time(), ) self._pending[approval.id] = approval logger.info(f"[Urbit] Approval {approval.id} created: {approval_type} from ~{requester_ship}") return approval def approve(self, approval_id: str) -> PendingApproval | None: approval = self._pending.pop(approval_id, None) if approval: approval.approve() self._resolved[approval_id] = approval logger.info(f"[Urbit] Approval {approval_id} approved") return approval def deny(self, approval_id: str) -> PendingApproval | None: approval = self._pending.pop(approval_id, None) if approval: approval.deny() self._resolved[approval_id] = approval logger.info(f"[Urbit] Approval {approval_id} denied") return approval def get_pending(self) -> list[PendingApproval]: return list(self._pending.values()) def get_pending_count(self) -> int: return len(self._pending) def block_ship(self, ship: str) -> bool: ship = ship.lstrip("~").lower() if ship in self._blocked_ships: return False self._blocked_ships.add(ship) logger.info(f"[Urbit] Ship blocked: ~{ship}") return True def unblock_ship(self, ship: str) -> bool: ship = ship.lstrip("~").lower() if ship not in self._blocked_ships: return False self._blocked_ships.discard(ship) logger.info(f"[Urbit] Ship unblocked: ~{ship}") return True def get_blocked_ships(self) -> list[str]: return sorted(self._blocked_ships) def is_ship_blocked(self, ship: str) -> bool: return ship.lstrip("~").lower() in self._blocked_ships def has_approved(self, ship: str) -> bool: normalized = ship.lstrip("~").lower() for approval in self._pending.values(): if approval.requester_ship.lstrip("~").lower() == normalized and approval.status == "approved": return True for approval in self._resolved.values(): if approval.requester_ship.lstrip("~").lower() == normalized and approval.status == "approved": return True return False def has_duplicate_pending(self, approval_type: str, requester_ship: str, channel_id: str) -> bool: req_ship = requester_ship.lstrip("~").lower() for p in self._pending.values(): if ( p.approval_type == approval_type and p.requester_ship.lstrip("~").lower() == req_ship and p.channel_id == channel_id and p.status == "pending" ): return True return False def build_owner_notification(self, approval: PendingApproval) -> str: return ( f"New {approval.approval_type} request from ~{approval.requester_ship}" f" (ID: {approval.id}): {approval.message_content[:200]}" ) def build_approval_instructions(self, approval_id: str) -> str: return ( f"To approve: send `approve {approval_id}`\n" f"To deny: send `deny {approval_id}`\n" f"To block: send `block {approval_id}`\n" f"To list pending: send `pending`\n" f"To list blocked: send `blocked`" ) def is_approval_command(self, content: str) -> bool: return any( content.strip().startswith(cmd) for cmd in ("approve ", "deny ", "block ", "unblock ", "approvals", "pending", "blocked") ) def handle_admin_command(self, content: str, sender_ship: str) -> str | None: if not self._owner_ship or sender_ship.lower() != self._owner_ship.lower(): return None content = content.strip() if content.startswith("approve "): aid = content.split(" ", 1)[1].strip() approval = self.approve(aid) if approval: return f"Approved {aid} (~{approval.requester_ship})" return f"Approval {aid} not found" if content.startswith("deny "): aid = content.split(" ", 1)[1].strip() approval = self.deny(aid) if approval: return f"Denied {aid} (~{approval.requester_ship})" return f"Approval {aid} not found" if content.startswith("block "): aid = content.split(" ", 1)[1].strip() approval = self._pending.get(aid) if approval: self.block_ship(approval.requester_ship) self.deny(aid) return f"Blocked and denied {aid} (~{approval.requester_ship})" return f"Approval {aid} not found" if content.startswith("unblock "): ship = content.split(" ", 1)[1].strip() if self.unblock_ship(ship): return f"Unblocked ~{ship}" return f"Ship ~{ship} is not blocked" if content in ("approvals", "pending"): pending = self.get_pending() if not pending: return "No pending approvals" lines = ["Pending approvals:"] for p in pending: lines.append(f" {p.id}: {p.approval_type} from ~{p.requester_ship}") return "\n".join(lines) if content == "blocked": blocked = self.get_blocked_ships() if not blocked: return "No blocked ships" lines = ["Blocked ships:"] for ship in blocked: lines.append(f" ~{ship}") return "\n".join(lines) return None async def persist_to_settings_store(self, store: SettingsStore) -> None: from .settings import SettingsStore store_data: dict[str, Any] = { "pending_approvals": [], "blocked_ships": sorted(self._blocked_ships), } for p in self._pending.values(): store_data["pending_approvals"].append( { "id": p.id, "type": p.approval_type, "requester": p.requester_ship, "channel": p.channel_id, "content": p.message_content[:500], "created_at": p.created_at, "status": p.status, } ) try: ok_pending = store.merge_key("pendingApprovals", store_data["pending_approvals"]) ok_blocked = store.merge_key("blockedShips", store_data["blocked_ships"]) if not ok_pending or not ok_blocked: logger.warning("[Urbit] Settings Store merge conflict, retrying...") await store.reload() store.merge_key("pendingApprovals", store_data["pending_approvals"]) store.merge_key("blockedShips", store_data["blocked_ships"]) logger.debug( f"[Urbit] Persisted {len(self._pending)} pending approvals " f"and {len(self._blocked_ships)} blocked ships to Settings Store" ) except Exception as e: logger.warning(f"[Urbit] Failed to persist approvals: {e}") async def load_from_settings_store(self, client: Any) -> list[PendingApproval]: from .settings import SettingsStore try: store = SettingsStore(client) data = await store.load() stored = data.get("pending_approvals", []) stored_blocked = data.get("blocked_ships", []) if isinstance(stored_blocked, list): for ship in stored_blocked: self._blocked_ships.add(ship.lstrip("~").lower()) loaded: list[PendingApproval] = [] for item in stored: approval = PendingApproval( id=item.get("id", str(uuid.uuid4())[:8]), approval_type=item.get("type", "dm"), requester_ship=item.get("requester", ""), channel_id=item.get("channel", ""), message_content=item.get("content", ""), created_at=item.get("created_at", 0), status=item.get("status", "pending"), ) if approval.status == "pending": self._pending[approval.id] = approval else: self._resolved[approval.id] = approval loaded.append(approval) if loaded or stored_blocked: logger.info( f"[Urbit] Loaded {len(loaded)} approvals " f"and {len(self._blocked_ships)} blocked ships from Settings Store" ) return loaded except Exception as e: logger.warning(f"[Urbit] Failed to load approvals: {e}") return []