ForcePilot/backend/package/yuxi/channels/adapters/slack/approval.py

152 lines
5.1 KiB
Python
Raw Normal View History

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)