""" health.py — Servidor HTTP para health check y webhook de Telegram. Rutas: GET /health → JSON con estado actual del bot GET /health/history → últimos N eventos del audit_log (?limit=50) POST /webhook → recibe updates de Telegram (solo en modo webhook) Modo webhook opt-in: solo se activa si settings.webhook_url está configurado. En modo long-polling, /webhook no se registra. """ from __future__ import annotations import json from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING import asyncio import aiohttp.web import structlog from telegram import Update if TYPE_CHECKING: from config import Settings from telegram.ext import Application logger = structlog.get_logger(__name__) # ── Estado global del poller ────────────────────────────────────────────────── _last_poll_at: datetime | None = None _last_poll_tenant_count: int = 0 _alert_sent: bool = False # evitar spam de alertas consecutivas def record_poll(tenant_count: int) -> None: """Llamar desde poller.py después de cada ronda automática.""" global _last_poll_at, _last_poll_tenant_count, _alert_sent _last_poll_at = datetime.now(timezone.utc) _last_poll_tenant_count = tenant_count _alert_sent = False # ── Servidor ────────────────────────────────────────────────────────────────── class HealthServer: def __init__(self, settings: Settings, application: Application) -> None: self._settings = settings self._app_ptb = application self._runner: aiohttp.web.AppRunner | None = None self._watcher_task: asyncio.Task | None = None async def start(self) -> None: app = aiohttp.web.Application() app.router.add_get("/health", self._handle_health) app.router.add_get("/health/history", self._handle_history) if self._settings.webhook_url: app.router.add_post("/webhook", self._handle_webhook) logger.info("webhook_endpoint_activo", url=f"{self._settings.webhook_url}/webhook") self._runner = aiohttp.web.AppRunner(app) await self._runner.setup() site = aiohttp.web.TCPSite(self._runner, "0.0.0.0", self._settings.health_port) await site.start() logger.info("health_server_iniciado", port=self._settings.health_port) self._watcher_task = asyncio.create_task(self._watcher_loop()) async def stop(self) -> None: if self._watcher_task: self._watcher_task.cancel() try: await self._watcher_task except asyncio.CancelledError: pass if self._runner: await self._runner.cleanup() logger.info("health_server_detenido") # ── Handlers ───────────────────────────────────────────────────────────── async def _handle_health(self, request: aiohttp.web.Request) -> aiohttp.web.Response: from database import get_conn conn = get_conn() async with conn.execute( "SELECT COUNT(*) as n FROM tenants WHERE estado = 'activo'" ) as cur: row = await cur.fetchone() tenant_count = row["n"] if row else 0 now = datetime.now(timezone.utc) seconds_since_poll: int | None = None stale = False if _last_poll_at: delta = now - _last_poll_at seconds_since_poll = int(delta.total_seconds()) stale = delta > timedelta(hours=self._settings.health_alert_hours) data = { "status": "degraded" if stale else "ok", "timestamp": now.isoformat(), "tenants_activos": tenant_count, "last_poll": _last_poll_at.isoformat() if _last_poll_at else None, "last_poll_tenants": _last_poll_tenant_count, "seconds_since_last_poll": seconds_since_poll, "poll_interval_minutes": self._settings.poll_interval_minutes, "webhook_mode": bool(self._settings.webhook_url), } return aiohttp.web.Response( text=json.dumps(data, indent=2), content_type="application/json", status=503 if stale else 200, ) async def _handle_history(self, request: aiohttp.web.Request) -> aiohttp.web.Response: from database import get_conn conn = get_conn() try: limit = min(int(request.rel_url.query.get("limit", "50")), 200) except (ValueError, TypeError): limit = 50 async with conn.execute( "SELECT timestamp, timestamp_local, chat_id, evento, resultado, duracion_ms " "FROM audit_log ORDER BY id DESC LIMIT ?", (limit,), ) as cur: rows = await cur.fetchall() return aiohttp.web.Response( text=json.dumps([dict(r) for r in rows], indent=2), content_type="application/json", ) async def _handle_webhook(self, request: aiohttp.web.Request) -> aiohttp.web.Response: secret = request.headers.get("X-Telegram-Bot-Api-Secret-Token", "") if self._settings.webhook_secret and secret != self._settings.webhook_secret: logger.warning("webhook_token_invalido", ip=request.remote) return aiohttp.web.Response(status=403, text="Forbidden") try: body = await request.json() except Exception: return aiohttp.web.Response(status=400, text="Bad Request") update = Update.de_json(data=body, bot=self._app_ptb.bot) await self._app_ptb.update_queue.put(update) return aiohttp.web.Response(status=200, text="OK") # ── Watcher ─────────────────────────────────────────────────────────────── async def _watcher_loop(self) -> None: """Alerta al admin si el poller lleva más de health_alert_hours sin actualizar.""" global _alert_sent while True: await asyncio.sleep(300) # revisar cada 5 minutos if _last_poll_at is None or _alert_sent: continue delta = datetime.now(timezone.utc) - _last_poll_at if delta <= timedelta(hours=self._settings.health_alert_hours): continue minutos = int(delta.total_seconds() // 60) try: await self._app_ptb.bot.send_message( chat_id=self._settings.telegram_chat_id, text=( f"⚠️ El poller no actualizó hace {minutos} minutos.\n" "Puede haber un problema con el bot o la conexión al PJ." ), ) _alert_sent = True logger.warning("health_alerta_enviada", minutos=minutos) except Exception as e: logger.error("health_alerta_error", error=str(e))