from __future__ import annotations import asyncio import logging import time from contextlib import asynccontextmanager from datetime import datetime, timezone from typing import Any import httpx from fastapi import Depends, FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from backend import config, db, telegram from backend.middleware import rate_limit_register, verify_webhook_secret from backend.models import ( RegisterRequest, RegisterResponse, SignalRequest, SignalResponse, ) logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # aggregator = telegram.SignalAggregator(interval=10) # v2.0 feature — отключено в v1 (ADR-004) _KEEPALIVE_INTERVAL = 600 # 10 минут async def _keep_alive_loop(app_url: str) -> None: """Периодически пингует /health чтобы предотвратить cold start на бесплатных хостингах.""" health_url = f"{app_url.rstrip('/')}/health" async with httpx.AsyncClient(timeout=10.0) as client: while True: await asyncio.sleep(_KEEPALIVE_INTERVAL) try: resp = await client.get(health_url) logger.info("Keep-alive ping %s → %d", health_url, resp.status_code) except Exception as exc: logger.warning("Keep-alive ping failed: %s", exc) @asynccontextmanager async def lifespan(app: FastAPI): # Startup app.state.rate_counters = {} await db.init_db() logger.info("Database initialized") if config.WEBHOOK_ENABLED: await telegram.set_webhook(url=config.WEBHOOK_URL, secret=config.WEBHOOK_SECRET) logger.info("Webhook registered") # v2.0 feature — агрегатор отключён в v1 (ADR-004) # task = asyncio.create_task(aggregator.run()) # logger.info("Aggregator started") keepalive_task: asyncio.Task | None = None if config.APP_URL: keepalive_task = asyncio.create_task(_keep_alive_loop(config.APP_URL)) logger.info("Keep-alive task started (target: %s/health)", config.APP_URL) else: logger.info("APP_URL not set — keep-alive disabled") yield # Shutdown if keepalive_task is not None: keepalive_task.cancel() try: await keepalive_task except asyncio.CancelledError: pass logger.info("Keep-alive task stopped") # aggregator.stop() # await aggregator.flush() # task.cancel() # try: # await task # except asyncio.CancelledError: # pass # logger.info("Aggregator stopped, final flush done") app = FastAPI(lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=[config.FRONTEND_ORIGIN], allow_methods=["POST"], allow_headers=["Content-Type"], ) @app.get("/health") async def health() -> dict[str, Any]: return {"status": "ok", "timestamp": int(time.time())} @app.post("/api/register", response_model=RegisterResponse) async def register(body: RegisterRequest, _: None = Depends(rate_limit_register)) -> RegisterResponse: result = await db.register_user(uuid=body.uuid, name=body.name) return RegisterResponse(user_id=result["user_id"], uuid=result["uuid"]) @app.post("/api/signal", response_model=SignalResponse) async def signal(body: SignalRequest) -> SignalResponse: geo = body.geo lat = geo.lat if geo else None lon = geo.lon if geo else None accuracy = geo.accuracy if geo else None signal_id = await db.save_signal( user_uuid=body.user_id, timestamp=body.timestamp, lat=lat, lon=lon, accuracy=accuracy, ) user_name = await db.get_user_name(body.user_id) ts = datetime.fromtimestamp(body.timestamp / 1000, tz=timezone.utc) name = user_name or body.user_id[:8] geo_info = ( f"📍 {lat}, {lon} (±{accuracy}м)" if geo else "Без геолокации" ) text = ( f"🚨 Сигнал от {name}\n" f"⏰ {ts.strftime('%H:%M:%S')} UTC\n" f"{geo_info}" ) await telegram.send_message(text) return SignalResponse(status="ok", signal_id=signal_id) @app.post("/api/webhook/telegram") async def webhook_telegram( request: Request, _: None = Depends(verify_webhook_secret), ) -> dict[str, Any]: update = await request.json() message = update.get("message", {}) text = message.get("text", "") if text.startswith("/start"): tg_user = message.get("from", {}) tg_user_id = str(tg_user.get("id", "")) first_name = tg_user.get("first_name", "") last_name = tg_user.get("last_name", "") name = (first_name + " " + last_name).strip() or tg_user_id if tg_user_id: await db.register_user(uuid=tg_user_id, name=name) logger.info("Telegram /start: registered user %s", tg_user_id) return {"ok": True}