feat: Docker, health check, schema migrations y webhook opt-in

- migrations/001_initial_schema.sql: DDL idempotente con CREATE TABLE/INDEX IF NOT EXISTS
- database.py: run_migrations() aplica .sql pendientes en orden; init_db() queda como alias
- health.py: servidor aiohttp en puerto 8080 — GET /health, GET /health/history, POST /webhook
  Watcher loop alerta al admin por Telegram si el poller no actualiza en N horas
- config.py: webhook_url, webhook_secret, health_port, health_alert_hours
- poller.py: on_poll_done callback (conecta con health.record_poll)
- main.py: arranca HealthServer, llama run_migrations, modo webhook opt-in via WEBHOOK_URL
- Dockerfile + docker-compose.yml + .dockerignore
- pyproject.toml: aiohttp>=3.9

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
markosbenitez
2026-05-25 23:31:18 -03:00
parent 537ed6931d
commit 076bc100af
11 changed files with 886 additions and 68 deletions

View File

@@ -9,6 +9,7 @@ Responsabilidades:
from __future__ import annotations
import asyncio
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
@@ -42,9 +43,11 @@ class Poller:
self,
settings: Settings,
bot: object, # PedritoBot — evitamos import circular
on_poll_done: Callable[[int], None] | None = None,
) -> None:
self._settings = settings
self._bot = bot
self._on_poll_done = on_poll_done
self._running = False
self._snapshots: dict[int, Snapshot] = {}
self._errores_consecutivos: dict[int, int] = {}
@@ -163,23 +166,27 @@ class Poller:
# API pública
# ──────────────────────────────────────────────────────────────────────────
async def revisar_ahora(self, chat_id: int | None = None, es_manual: bool = False) -> None:
async def revisar_ahora(self, chat_id: int | None = None, es_manual: bool = False) -> int:
"""
Si chat_id: revisar solo ese tenant (para /notif manual).
Si None: revisar todos los tenants activos.
Retorna la cantidad de tenants revisados.
"""
if chat_id is not None:
tenant = await get_tenant(chat_id)
if tenant and tenant.get("estado") == "activo":
await self._revisar_tenant(tenant, es_manual=es_manual)
return 1
elif es_manual and tenant:
logger.warning("revisar_ahora_tenant_no_activo", chat_id=chat_id, estado=tenant.get("estado"))
return 0
else:
tenants = await list_active_tenants()
if not tenants:
logger.info("sin_tenants_activos_skip")
return
return 0
await asyncio.gather(*(self._revisar_tenant(t) for t in tenants))
return len(tenants)
def disparar_revision_manual(self, chat_id: int) -> None:
"""Llamado desde el handler de /notif para disparar revisión inmediata."""
@@ -199,7 +206,9 @@ class Poller:
)
logger.info("revision_inicial_al_arrancar")
await self.revisar_ahora()
count = await self.revisar_ahora()
if self._on_poll_done:
self._on_poll_done(count)
while self._running:
try:
@@ -211,6 +220,8 @@ class Poller:
except asyncio.TimeoutError:
if self._en_horario_habitual():
await self.revisar_ahora()
count = await self.revisar_ahora()
if self._on_poll_done:
self._on_poll_done(count)
else:
logger.debug("fuera_de_horario_skip")