232 lines
6.8 KiB
Python
232 lines
6.8 KiB
Python
"""
|
|
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/<pid>/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())
|