""" main.py — Entrypoint de Pedrito Hechakuaa. Arranca el bot de Telegram, el poller y el servidor HTTP de health check en paralelo. Al arrancar: aplica migraciones y migra el tenant principal del .env si no existe. """ from __future__ import annotations import asyncio import os import signal import sys import threading import structlog from telegram.ext import Application import health as health_mod import vault as vault_mod from config import get_settings from database import get_tenant, run_migrations, save_tenant from health import HealthServer, record_poll from onboarding import OnboardingFlow from poller import Poller from telegram_bot import PedritoBot # Configurar structlog structlog.configure( processors=[ structlog.contextvars.merge_contextvars, structlog.processors.add_log_level, structlog.processors.TimeStamper(fmt="iso"), structlog.dev.ConsoleRenderer() if sys.stderr.isatty() else structlog.processors.JSONRenderer(), ] ) logger = structlog.get_logger(__name__) async def _migrar_tenant_principal(settings) -> None: """ Migra el tenant principal del .env a la DB si todavía no existe. Permite que el dueño del bot siga funcionando sin re-registrarse. """ chat_id = settings.telegram_chat_id existente = await get_tenant(chat_id) if existente: logger.info("tenant_principal_ya_existe", chat_id=chat_id) return logger.info("migrando_tenant_principal_del_env", chat_id=chat_id) usuario = settings.descifrar_usuario() password = settings.descifrar_password() try: credentials_enc = vault_mod.cifrar_credenciales( chat_id=chat_id, usuario=usuario, password=password, fernet_key=settings.fernet_key, ) finally: del usuario, password await save_tenant( chat_id, nombre_preferido="Admin", tono="cotidiano", timezone="America/Asuncion", credentials_enc=credentials_enc, estado="activo", consentimiento_aceptado=1, ) logger.info("tenant_principal_migrado", chat_id=chat_id) def _limpiar_env_sensibles() -> None: """ Elimina variables sensibles del entorno del proceso después de cargar Settings. Protege contra: - cat /proc//environ (cualquier usuario con acceso al PID) - herencia accidental de env vars por subprocesos - stack traces que vuelquen os.environ NO protege contra 'docker inspect' (que lee la config del contenedor, no el env live). Para eso usar Docker secrets con Swarm mode. """ _sensibles = ( "FERNET_KEY", "TELEGRAM_BOT_TOKEN", "PJ_USUARIO_ENC", "PJ_PASSWORD_ENC", "WEBHOOK_SECRET", "HEALTH_AUTH_TOKEN", "INVITE_CODE", "ADMIN_TOKEN", ) for var in _sensibles: os.environ.pop(var, None) def _run_admin(settings) -> None: """Arranca el panel de administración en su propio thread y event loop.""" import uvicorn from admin.app import create_app if not settings.admin_token: logger.warning("admin_panel_sin_token_no_iniciado") return admin_app = create_app( db_path=str(settings.db_path), admin_token=settings.admin_token, data_dir=settings.data_dir, ) uvicorn.run( admin_app, host="127.0.0.1", port=settings.admin_port, log_level="warning", ) async def main() -> None: settings = get_settings() _limpiar_env_sensibles() logger.info( "pedrito_hechakuaa_iniciando", poll_interval_minutes=settings.poll_interval_minutes, horario=f"{settings.poll_hora_inicio}:00-{settings.poll_hora_fin}:00", chat_id=settings.telegram_chat_id, webhook_mode=bool(settings.webhook_url), ) # Migraciones y tenant principal await run_migrations(settings.db_path) await _migrar_tenant_principal(settings) # Bot de Telegram onboarding = OnboardingFlow(settings) pedrito_bot = PedritoBot( token=settings.telegram_bot_token, settings=settings, onboarding=onboarding, ) app: Application = pedrito_bot.build() # Poller poller = Poller( settings=settings, bot=pedrito_bot, on_poll_done=record_poll, ) pedrito_bot.set_notif_callback( lambda chat_id: poller.revisar_ahora(chat_id=chat_id, es_manual=True) ) # Panel de administración (thread daemon — muere con el proceso principal) admin_thread = threading.Thread(target=_run_admin, args=(settings,), daemon=True) admin_thread.start() # Manejo de señales para shutdown limpio loop = asyncio.get_running_loop() health_server: HealthServer | None = None def handle_shutdown(sig: int) -> None: logger.info("shutdown_recibido", signal=sig) poller.detener() loop.stop() for sig in (signal.SIGINT, signal.SIGTERM): try: loop.add_signal_handler(sig, handle_shutdown, sig) except NotImplementedError: pass # Windows no soporta add_signal_handler async with app: await app.initialize() await app.start() logger.info("bot_telegram_iniciado") # Servidor HTTP de health check (y webhook si está configurado) health_server = HealthServer(settings=settings, application=app) await health_server.start() if settings.webhook_url: # Registrar el webhook con Telegram webhook_url = f"{settings.webhook_url.rstrip('/')}/webhook" await app.bot.set_webhook( url=webhook_url, secret_token=settings.webhook_secret or None, drop_pending_updates=True, ) logger.info("webhook_registrado", url=webhook_url) telegram_task = asyncio.create_task(asyncio.sleep(0)) # placeholder else: # Long-polling telegram_task = asyncio.create_task( app.updater.start_polling(drop_pending_updates=True) ) poller_task = asyncio.create_task(poller.run_forever()) logger.info("pedrito_hechakuaa_corriendo") try: if settings.webhook_url: await poller_task else: await asyncio.gather(telegram_task, poller_task) except asyncio.CancelledError: pass finally: if health_server: await health_server.stop() if settings.webhook_url: try: await app.bot.delete_webhook() except Exception: pass else: await app.updater.stop() await app.stop() logger.info("pedrito_hechakuaa_detenido") if __name__ == "__main__": asyncio.run(main())