""" Движок выполнения диалоговых сценариев из YAML-файлов. Каждый сценарий — это последовательность шагов: 1. Отправить сообщение роботу (POST /ses/ask) 2. Проверить ответ по условиям (expect) 3. Перейти к следующему шагу Сценарии загружаются из config/scenarios/ во время старта и автоматически перечитываются каждые 60 секунд (hot-reload). """ from __future__ import annotations import logging import time from dataclasses import dataclass, field from typing import Any, Dict, List, Optional from ses_monitor.config import ScenarioConfig, ScenarioStep, SESConfig from ses_monitor.session_client import SESClient, AskResponse from ses_monitor.utils import check_all_expectations, timer log = logging.getLogger("ses_monitor.scenario") @dataclass class StepResult: """Результат выполнения одного шага сценария.""" step_index: int text_sent: str response: Optional[AskResponse] = None passed: bool = True errors: List[str] = field(default_factory=list) response_time_ms: float = 0.0 @dataclass class ScenarioResult: """Результат выполнения полного сценария.""" scenario_name: str passed: bool = True total_time_ms: float = 0.0 step_results: List[StepResult] = field(default_factory=list) error_message: str = "" @property def failed_steps(self) -> List[StepResult]: """Шаги, которые не прошли проверку.""" return [s for s in self.step_results if not s.passed] def aggregate_scenario_results( results: List[ScenarioResult], ) -> Dict[str, Any]: """Агрегирует результаты всех сценариев в фиксированный набор Zabbix-метрик. Возвращает словарь с ключами: ok (int): 1 если все прошли, 0 если есть упавшие. total (int): общее количество выполненных сценариев. failed (int): количество упавших сценариев. avg_time (int): среднее время выполнения (мс), округлённое. max_time (int): максимальное время выполнения (мс), округлённое. error (str): текст ошибки ("N сценариев упали") или пустая строка. """ if not results: return { "ok": 1, "total": 0, "failed": 0, "avg_time": 0, "max_time": 0, "error": "", } failed = sum(1 for r in results if not r.passed) total = len(results) times = [r.total_time_ms for r in results] return { "ok": 0 if failed else 1, "total": total, "failed": failed, "avg_time": round(sum(times) / total), "max_time": round(max(times)), "error": f"{failed} сценариев упали" if failed else "", } class ScenarioRunner: """Выполняет диалоговые сценарии мониторинга. Поддерживает: - Переиспользование HTTP-клиента между проверками. - Hot-reload сценариев из каталога. - Автоматическое создание и отслеживание сессий. """ def __init__(self, ses_config: SESConfig, scenarios: List[ScenarioConfig]): self._ses_config = ses_config self._scenarios = scenarios def update_scenarios(self, scenarios: List[ScenarioConfig]) -> None: """Обновляет список сценариев (hot-reload).""" self._scenarios = scenarios async def run_all( self, client: SESClient ) -> List[ScenarioResult]: """Запускает все сценарии последовательно. Args: client: HTTP-клиент для SES API. Returns: Список результатов выполнения сценариев. """ results: List[ScenarioResult] = [] for scenario in self._scenarios: result = await self.run_one(client, scenario) results.append(result) return results async def run_one( self, client: SESClient, scenario: ScenarioConfig ) -> ScenarioResult: """Выполняет один сценарий. Args: client: HTTP-клиент для SES API. scenario: Конфигурация сценария. Returns: Результат выполнения. """ result = ScenarioResult( scenario_name=scenario.name, ) # Уникальный ID мониторинговой сессии session_id = f"monitoring_{scenario.name.replace(' ', '_')}_{int(time.time())}" log.info("Запуск сценария '%s' (сессия %s)", scenario.name, session_id) with timer() as total_time: try: for i, step in enumerate(scenario.steps): step_result = await self._execute_step( client, scenario, session_id, i, step ) result.step_results.append(step_result) if not step_result.passed: result.passed = False log.warning( "Сценарий '%s': шаг %d НЕ ПРОЙДЕН: %s", scenario.name, i + 1, step_result.errors, ) break # Прерываем сценарий при первой ошибке if result.passed and result.step_results: log.info( "Сценарий '%s': УСПЕХ (%d шагов)", scenario.name, len(result.step_results), ) except Exception as e: result.passed = False result.error_message = f"Исключение: {e}" log.exception("Сценарий '%s': исключение", scenario.name) result.total_time_ms = total_time.get("elapsed", 0) * 1000 return result async def _execute_step( self, client: SESClient, scenario: ScenarioConfig, session_id: str, step_index: int, step: ScenarioStep, ) -> StepResult: """Выполняет один шаг сценария: отправка → проверка ответа. Args: client: HTTP-клиент. scenario: Конфигурация сценария. session_id: ID текущей сессии. step_index: Номер шага (0-based). step: Определение шага. Returns: Результат шага. """ result = StepResult( step_index=step_index, text_sent=step.send, ) try: # Отправляем сообщение роботу with timer() as step_time: response = await client.ask( text=step.send, session_id=session_id, robot_id=scenario.robot_id, channel=scenario.channel, restart_closed=scenario.restart_closed, ) result.response = response result.response_time_ms = step_time.get("elapsed", 0) * 1000 # Проверяем базовую ошибку API if response.error != 0: result.passed = False result.errors.append( f"SES API error={response.error} на сообщение '{step.send[:80]}'" ) return result # Проверяем ожидания if step.expect: passed, errors = check_all_expectations(response.raw, step.expect) result.passed = passed result.errors = errors if not result.passed and not step.expect: # Если ожиданий не было — считаем что ответ error=0 это успех result.passed = True except Exception as e: result.passed = False result.errors.append(f"HTTP ошибка: {e}") log.exception( "Сценарий '%s', шаг %d: исключение", scenario.name, step_index + 1, ) return result