Универсальный watchdog-мониторинг Service Engine Server (SES): - Liveness-проверки (API, лицензия, роботы) - Диалоговые сценарии из YAML с агрегацией в 6 фиксированных Zabbix-метрик - Доставка: zabbix_sender (active push) или UserParameter (пассивный опрос) - Поддержка Python 3.7+, Zabbix 5.0
303 lines
9.9 KiB
Python
303 lines
9.9 KiB
Python
"""
|
||
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}")
|