from __future__ import annotations import time from collections.abc import Callable from dataclasses import dataclass from typing import Any, Literal from yuxi.utils.logging_config import logger DEFAULT_APPROVAL_TIMEOUT_MINUTES = 5 DEFAULT_MAX_PENDING_APPROVALS = 50 ApprovalCallback = Callable[[str, str, dict[str, Any]], None] @dataclass class ApprovalRequest: request_id: str action: str channel_user_id: str chat_id: str payload: dict[str, Any] created_at: float expires_at: float status: str = "pending" chat_type: Literal["direct", "group"] = "direct" class WeChatApprovalAdapter: def __init__(self): self._pending: dict[str, ApprovalRequest] = {} self._history: list[dict[str, Any]] = [] self._max_history = 200 self._enabled: bool = False self._timeout_minutes: int = DEFAULT_APPROVAL_TIMEOUT_MINUTES self._dm_enabled: bool = True self._group_enabled: bool = True self._callbacks: dict[str, list[ApprovalCallback]] = { "approved": [], "denied": [], "timeout": [], } def configure(self, config: dict[str, Any]) -> None: approval_cfg = config.get("approval", {}) self._enabled = approval_cfg.get("enabled", False) self._timeout_minutes = approval_cfg.get("timeout_minutes", DEFAULT_APPROVAL_TIMEOUT_MINUTES) self._dm_enabled = approval_cfg.get("dm_enabled", True) self._group_enabled = approval_cfg.get("group_enabled", True) logger.info( f"[WeChat/Approval] Configured: enabled={self._enabled}, " f"timeout={self._timeout_minutes}m, dm={self._dm_enabled}, group={self._group_enabled}" ) def on(self, event: Literal["approved", "denied", "timeout"], callback: ApprovalCallback) -> None: self._callbacks[event].append(callback) def _trigger_callbacks(self, event: str, request_id: str, outcome: str, payload: dict[str, Any]) -> None: for cb in self._callbacks.get(event, []): try: cb(request_id, outcome, payload) except Exception as e: logger.error(f"[WeChat/Approval] Callback error for {event}: {e}") def is_chat_type_enabled(self, chat_type: Literal["direct", "group"]) -> bool: if not self._enabled: return False if chat_type == "direct": return self._dm_enabled return self._group_enabled def create_request( self, request_id: str, action: str, channel_user_id: str, chat_id: str, payload: dict[str, Any] | None = None, chat_type: Literal["direct", "group"] = "direct", ) -> ApprovalRequest | None: if not self._enabled: return None if not self.is_chat_type_enabled(chat_type): return None self._cleanup_expired() if len(self._pending) >= DEFAULT_MAX_PENDING_APPROVALS: logger.warning("[WeChat/Approval] Max pending approvals reached") return None now = time.time() req = ApprovalRequest( request_id=request_id, action=action, channel_user_id=channel_user_id, chat_id=chat_id, payload=payload or {}, created_at=now, expires_at=now + self._timeout_minutes * 60, chat_type=chat_type, ) self._pending[request_id] = req logger.info(f"[WeChat/Approval] Created: {request_id} for '{action}' by {channel_user_id} [{chat_type}]") return req def approve(self, request_id: str, approver_id: str = "admin") -> bool: req = self._pending.get(request_id) if req is None: return False if req.status != "pending": return False req.status = "approved" self._record_history(req, approver_id, "approved") self._pending.pop(request_id, None) logger.info(f"[WeChat/Approval] Approved: {request_id} by {approver_id}") self._trigger_callbacks("approved", request_id, "approved", req.payload) return True def deny(self, request_id: str, reason: str = "", approver_id: str = "admin") -> bool: req = self._pending.get(request_id) if req is None: return False if req.status != "pending": return False req.status = "denied" self._record_history(req, approver_id, "denied", reason) self._pending.pop(request_id, None) logger.info(f"[WeChat/Approval] Denied: {request_id} reason={reason}") self._trigger_callbacks("denied", request_id, "denied", {**req.payload, "reason": reason}) return True def is_approved(self, request_id: str) -> bool: req = self._pending.get(request_id) return req is not None and req.status == "approved" def is_pending(self, request_id: str) -> bool: req = self._pending.get(request_id) return req is not None and req.status == "pending" def list_pending(self, chat_type: Literal["direct", "group"] | None = None) -> list[dict[str, Any]]: self._cleanup_expired() result = [] for r in self._pending.values(): if chat_type and r.chat_type != chat_type: continue result.append( { "request_id": r.request_id, "action": r.action, "channel_user_id": r.channel_user_id, "chat_id": r.chat_id, "chat_type": r.chat_type, "created_at": r.created_at, "expires_at": r.expires_at, "status": r.status, } ) return result def resolve_timeout_requests(self) -> list[str]: expired: list[str] = [] now = time.time() for req_id, req in list(self._pending.items()): if req.expires_at < now: req.status = "timeout" self._record_history(req, "system", "timeout") self._pending.pop(req_id, None) expired.append(req_id) self._trigger_callbacks("timeout", req_id, "timeout", req.payload) if expired: logger.info(f"[WeChat/Approval] Timed out: {len(expired)} request(s)") return expired def get_pending_count(self, chat_type: Literal["direct", "group"] | None = None) -> int: if chat_type: return sum(1 for r in self._pending.values() if r.chat_type == chat_type) return len(self._pending) def _cleanup_expired(self) -> None: self.resolve_timeout_requests() def _record_history( self, req: ApprovalRequest, approver_id: str, outcome: str, reason: str = "", ) -> None: entry = { "request_id": req.request_id, "action": req.action, "channel_user_id": req.channel_user_id, "chat_id": req.chat_id, "chat_type": req.chat_type, "created_at": req.created_at, "resolved_at": time.time(), "outcome": outcome, "approver_id": approver_id, "reason": reason, } self._history.append(entry) if len(self._history) > self._max_history: self._history = self._history[-self._max_history :] def get_history(self, limit: int = 50) -> list[dict[str, Any]]: return list(reversed(self._history[-limit:]))