2026-03-20 20:44:00 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import logging
|
2026-03-20 21:03:45 +02:00
|
|
|
import time
|
2026-03-20 20:44:00 +02:00
|
|
|
from contextlib import asynccontextmanager
|
2026-03-20 20:50:31 +02:00
|
|
|
from datetime import datetime, timezone
|
2026-03-20 20:44:00 +02:00
|
|
|
from typing import Any
|
|
|
|
|
|
2026-03-20 21:03:45 +02:00
|
|
|
import httpx
|
2026-03-20 20:44:00 +02:00
|
|
|
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 verify_webhook_secret
|
|
|
|
|
from backend.models import (
|
|
|
|
|
RegisterRequest,
|
|
|
|
|
RegisterResponse,
|
|
|
|
|
SignalRequest,
|
|
|
|
|
SignalResponse,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2026-03-20 20:50:31 +02:00
|
|
|
# aggregator = telegram.SignalAggregator(interval=10) # v2.0 feature — отключено в v1 (ADR-004)
|
2026-03-20 20:44:00 +02:00
|
|
|
|
2026-03-20 21:03:45 +02:00
|
|
|
_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)
|
|
|
|
|
|
2026-03-20 20:44:00 +02:00
|
|
|
|
|
|
|
|
@asynccontextmanager
|
|
|
|
|
async def lifespan(app: FastAPI):
|
|
|
|
|
# Startup
|
|
|
|
|
await db.init_db()
|
|
|
|
|
logger.info("Database initialized")
|
|
|
|
|
|
2026-03-20 21:01:48 +02:00
|
|
|
if config.WEBHOOK_ENABLED:
|
|
|
|
|
await telegram.set_webhook(url=config.WEBHOOK_URL, secret=config.WEBHOOK_SECRET)
|
|
|
|
|
logger.info("Webhook registered")
|
2026-03-20 20:44:00 +02:00
|
|
|
|
2026-03-20 20:50:31 +02:00
|
|
|
# v2.0 feature — агрегатор отключён в v1 (ADR-004)
|
|
|
|
|
# task = asyncio.create_task(aggregator.run())
|
|
|
|
|
# logger.info("Aggregator started")
|
2026-03-20 20:44:00 +02:00
|
|
|
|
2026-03-20 21:03:45 +02:00
|
|
|
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")
|
|
|
|
|
|
2026-03-20 20:44:00 +02:00
|
|
|
yield
|
|
|
|
|
|
|
|
|
|
# Shutdown
|
2026-03-20 21:03:45 +02:00
|
|
|
if keepalive_task is not None:
|
|
|
|
|
keepalive_task.cancel()
|
|
|
|
|
try:
|
|
|
|
|
await keepalive_task
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
pass
|
|
|
|
|
logger.info("Keep-alive task stopped")
|
|
|
|
|
|
2026-03-20 20:50:31 +02:00
|
|
|
# aggregator.stop()
|
|
|
|
|
# await aggregator.flush()
|
|
|
|
|
# task.cancel()
|
|
|
|
|
# try:
|
|
|
|
|
# await task
|
|
|
|
|
# except asyncio.CancelledError:
|
|
|
|
|
# pass
|
|
|
|
|
# logger.info("Aggregator stopped, final flush done")
|
2026-03-20 20:44:00 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
app = FastAPI(lifespan=lifespan)
|
|
|
|
|
|
|
|
|
|
app.add_middleware(
|
|
|
|
|
CORSMiddleware,
|
|
|
|
|
allow_origins=[config.FRONTEND_ORIGIN],
|
|
|
|
|
allow_methods=["POST"],
|
|
|
|
|
allow_headers=["Content-Type"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-03-20 21:03:45 +02:00
|
|
|
@app.get("/health")
|
|
|
|
|
async def health() -> dict[str, Any]:
|
|
|
|
|
return {"status": "ok", "timestamp": int(time.time())}
|
|
|
|
|
|
|
|
|
|
|
2026-03-20 20:44:00 +02:00
|
|
|
@app.post("/api/register", response_model=RegisterResponse)
|
|
|
|
|
async def register(body: RegisterRequest) -> 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)
|
2026-03-20 20:50:31 +02:00
|
|
|
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}"
|
2026-03-20 20:44:00 +02:00
|
|
|
)
|
2026-03-20 20:50:31 +02:00
|
|
|
await telegram.send_message(text)
|
2026-03-20 20:44:00 +02:00
|
|
|
|
|
|
|
|
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}
|