ses-monitor/ses_monitor/session_client.py
Maksim Totmin 7394b14b52 Initial commit: ses-monitor watchdog for SES
Универсальный watchdog-мониторинг Service Engine Server (SES):
- Liveness-проверки (API, лицензия, роботы)
- Диалоговые сценарии из YAML с агрегацией в 6 фиксированных Zabbix-метрик
- Доставка: zabbix_sender (active push) или UserParameter (пассивный опрос)
- Поддержка Python 3.7+, Zabbix 5.0
2026-06-19 20:26:26 +07:00

303 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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}")