from __future__ import annotations import uuid from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from datetime import datetime, UTC, timedelta from enum import StrEnum from typing import Any class ApprovalStatus(StrEnum): PENDING = "pending" APPROVED = "approved" REJECTED = "rejected" EXPIRED = "expired" EXECUTED = "executed" FAILED = "failed" @dataclass class ApprovalRequest: approval_id: str title: str detail: str command: str chat_id: str message_ts: str created_by: str = "" approved_by: str = "" created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) status: ApprovalStatus = ApprovalStatus.PENDING exec_result: str = "" exec_error: str = "" @property def is_pending(self) -> bool: return self.status == ApprovalStatus.PENDING @property def is_resolved(self) -> bool: return self.status in (ApprovalStatus.APPROVED, ApprovalStatus.REJECTED, ApprovalStatus.EXPIRED) class ApprovalManager: def __init__(self, ttl_seconds: float = 3600.0): self._requests: dict[str, ApprovalRequest] = {} self._ttl_seconds = ttl_seconds self._exec_handlers: dict[str, Callable[..., Awaitable[dict[str, Any]]]] = {} def register_exec_handler(self, command_prefix: str, handler: Callable[..., Awaitable[dict[str, Any]]]) -> None: self._exec_handlers[command_prefix] = handler def create_approval( self, title: str, detail: str, command: str, chat_id: str, message_ts: str, created_by: str = "" ) -> ApprovalRequest: approval_id = uuid.uuid4().hex[:12] req = ApprovalRequest( approval_id=approval_id, title=title, detail=detail, command=command, chat_id=chat_id, message_ts=message_ts, created_by=created_by, ) self._requests[approval_id] = req self._cleanup_expired() return req def get_approval(self, approval_id: str) -> ApprovalRequest | None: return self._requests.get(approval_id) def approve(self, approval_id: str, approved_by: str = "") -> ApprovalRequest | None: req = self._requests.get(approval_id) if not req or not req.is_pending: return None req.status = ApprovalStatus.APPROVED req.approved_by = approved_by return req def reject(self, approval_id: str) -> ApprovalRequest | None: req = self._requests.get(approval_id) if not req or not req.is_pending: return None req.status = ApprovalStatus.REJECTED return req def mark_executed(self, approval_id: str, result: str = "") -> ApprovalRequest | None: req = self._requests.get(approval_id) if not req or req.status != ApprovalStatus.APPROVED: return None req.status = ApprovalStatus.EXECUTED req.exec_result = result return req def mark_failed(self, approval_id: str, error: str = "") -> ApprovalRequest | None: req = self._requests.get(approval_id) if not req or req.status != ApprovalStatus.APPROVED: return None req.status = ApprovalStatus.FAILED req.exec_error = error return req async def execute_approval(self, approval_id: str) -> dict[str, Any]: req = self._requests.get(approval_id) if not req or req.status != ApprovalStatus.APPROVED: return {"success": False, "error": "Approval not in approved state"} handler = None for prefix, h in self._exec_handlers.items(): if req.command.startswith(prefix): handler = h break if not handler: self.mark_failed(approval_id, f"No exec handler for command: {req.command}") return {"success": False, "error": f"No exec handler for command: {req.command}"} try: result = await handler(command=req.command, approval=req) self.mark_executed(approval_id, str(result)) return {"success": True, "result": result} except Exception as e: self.mark_failed(approval_id, str(e)) return {"success": False, "error": str(e)} def list_pending(self, chat_id: str | None = None) -> list[dict[str, Any]]: pending = [r for r in self._requests.values() if r.is_pending] if chat_id: pending = [r for r in pending if r.chat_id == chat_id] return [ { "approval_id": r.approval_id, "title": r.title, "command": r.command, "created_by": r.created_by, "created_at": r.created_at.isoformat(), "status": r.status.value, } for r in pending ] def _cleanup_expired(self) -> None: now = datetime.now(UTC) expired = [ aid for aid, req in self._requests.items() if req.is_resolved and now - req.created_at > timedelta(seconds=self._ttl_seconds) ] for aid in expired: self._requests.pop(aid, None)