""" HTTP-клиент для взаимодействия с SES REST API. Только чтение (GET). Для POST /ses/ask используется безопасный подход: каждый сценарий открывает новую сессию, тестовые запросы идут с префиксом 'monitoring_' в номере сессии. """ from __future__ import annotations import json import logging import time from dataclasses import dataclass from typing import Any, Dict, List, Optional from urllib.parse import urlencode, urljoin import httpx from ses_monitor.config import SESConfig log = logging.getLogger("ses_monitor.client") @dataclass class AskResponse: """Ответ на POST /ses/ask.""" error: int question: str answer: List[Dict[str, Any]] session: str uuid: str raw: Dict[str, Any] @classmethod def from_json(cls, data: Dict[str, Any]) -> "AskResponse": """Создаёт объект из JSON-ответа SES.""" return cls( error=data.get("error", -1), question=data.get("question", ""), answer=data.get("answer", []), session=data.get("session", ""), uuid=data.get("uuid", ""), raw=data, ) @dataclass class HostnameResponse: """Ответ на GET /ses/hostname.""" error: int message: str hostname: str version: str @classmethod def from_json(cls, data: Dict[str, Any]) -> "HostnameResponse": return cls( error=data.get("error", -1), message=data.get("message", ""), hostname=data.get("hostname", ""), version=data.get("version", ""), ) @dataclass class LicenseResponse: """Ответ на GET /license/check.""" error: int uuid: str remaining_licenses: str threshold: int @classmethod def from_json(cls, data: Dict[str, Any]) -> "LicenseResponse": return cls( error=data.get("error", -1), uuid=data.get("uuid", ""), remaining_licenses=data.get("remaining_licenses", ""), threshold=data.get("threshold", 0), ) @dataclass class ArchiveEntry: """Одна запись из архива диалогов.""" id: str session: str robot: str hostname: str channel: str unknown: bool closed: Any endpoint: Optional[str] answered: bool conversion: bool models: List[str] events: List[str] request_message: str last_reply: str raw: Dict[str, Any] @classmethod def from_json(cls, data: Dict[str, Any]) -> "ArchiveEntry": robot_raw = data.get("robot", "") if isinstance(robot_raw, list): robot_raw = robot_raw[0] if robot_raw else "" return cls( id=data.get("id", ""), session=data.get("session", ""), robot=robot_raw, hostname=data.get("hostname", ""), channel=data.get("channel", ""), unknown=data.get("unknown", False), closed=data.get("closed", False), endpoint=data.get("endpoint"), answered=data.get("answered", False), conversion=data.get("conversion", False), models=data.get("models", []), events=data.get("events", []), request_message=data.get("request", {}).get("message", ""), last_reply=data.get("current", {}).get("lastreply", ""), raw=data, ) class SESClient: """HTTP-клиент для SES REST API. Выполняет только GET и POST /ses/ask запросы. Все методы асинхронные для работы в asyncio-цикле. """ def __init__(self, config: SESConfig): self._base_url = config.base_url.rstrip("/") self._robot_id = config.robot_id self._channel = config.channel self._timeout = config.request_timeout self._robot_list_endpoint = config.robot_list_endpoint self._client: Optional[httpx.AsyncClient] = None async def _ensure_client(self) -> httpx.AsyncClient: """Ленивое создание HTTP-клиента.""" if self._client is None: self._client = httpx.AsyncClient( timeout=httpx.Timeout(self._timeout), limits=httpx.Limits(max_keepalive_connections=5), ) return self._client async def close(self) -> None: """Закрывает HTTP-клиент.""" if self._client is not None: await self._client.aclose() self._client = None async def _get(self, path: str) -> Dict[str, Any]: """Выполняет GET-запрос к SES API.""" url = urljoin(self._base_url, path) client = await self._ensure_client() try: response = await client.get(url) response.raise_for_status() data = response.json() return data except httpx.HTTPError as e: log.error("HTTP ошибка GET %s: %s", path, e) raise except json.JSONDecodeError as e: log.error("Ошибка парсинга JSON от %s: %s", path, e) raise async def _post(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Выполняет POST-запрос к SES API.""" url = urljoin(self._base_url, path) client = await self._ensure_client() try: response = await client.post(url, params=params) response.raise_for_status() data = response.json() return data except httpx.HTTPError as e: log.error("HTTP ошибка POST %s: %s", path, e) raise except json.JSONDecodeError as e: log.error("Ошибка парсинга JSON от %s: %s", path, e) raise # --------------- Liveness-проверки --------------- async def get_hostname(self) -> HostnameResponse: """GET /ses/hostname — проверка доступности сервера.""" data = await self._get("/ses/hostname") return HostnameResponse.from_json(data) async def get_license(self) -> LicenseResponse: """GET /license/check — проверка лицензии.""" data = await self._get("/license/check") return LicenseResponse.from_json(data) async def get_robots(self) -> List[Dict[str, Any]]: """GET robot list — список сценариев (роботов). Эндпоинт задаётся в config.yaml (robot_list_endpoint). /ses/robot/list — только опубликованные. /ses/robot/listall — все (включая черновики). """ data = await self._get(self._robot_list_endpoint) return data if isinstance(data, list) else [] # --------------- Диалоговые сценарии --------------- async def ask( self, text: str, *, session_id: Optional[str] = None, robot_id: Optional[str] = None, channel: Optional[str] = None, restart_closed: bool = False, ) -> AskResponse: """POST /ses/ask/{robot_id} — отправка сообщения роботу. Args: text: Текст сообщения. session_id: UUID сессии. Если None — генерируется новый. robot_id: ID робота. Если None — из конфигурации. channel: Канал. Если None — из конфигурации. restart_closed: Переоткрывать закрытую сессию. Returns: AskResponse с ответом робота. """ rid = robot_id or self._robot_id ch = channel or self._channel if session_id is None: session_id = f"monitoring_{int(time.time())}" params = { "text": text, "channel": ch, "session": session_id, "restartClosed": int(restart_closed), } data = await self._post(f"/ses/ask/{rid}", params=params) return AskResponse.from_json(data) # --------------- Архив диалогов --------------- async def get_archive( self, start_date: str, end_date: str, **filters: Any, ) -> List[ArchiveEntry]: """GET /ses/archive/list — получение истории диалогов. Args: start_date: Начало периода, формат "YYYY-MM-DD HH:MM". end_date: Конец периода, формат "YYYY-MM-DD HH:MM". **filters: Дополнительные параметры (channel, robot, text, etc.). Returns: Список записей архива. """ params: Dict[str, Any] = { "startdate": start_date, "enddate": end_date, **filters, } data = await self._get(f"/ses/archive/list?{urlencode(params)}") if isinstance(data, list): return [ArchiveEntry.from_json(entry) for entry in data] return [] async def get_dialog(self, dialog_uuid: str) -> Dict[str, Any]: """GET /ses/archive/dialog/{uuid} — получение диалога по UUID.""" return await self._get(f"/ses/archive/dialog/{dialog_uuid}") # --------------- Логи --------------- async def get_logs(self, date: str) -> Dict[str, Any]: """POST /ses/log — получение логов за дату.""" params = {"date": date} return await self._post("/ses/log", params=params) # --------------- TestAPI --------------- async def call_test_api(self, name: str) -> Any: """GET /ses/testapi/{name} — вызов тестового API.""" return await self._get(f"/ses/testapi/{name}")