from __future__ import annotations import asyncio import json import logging import secrets from contextlib import asynccontextmanager from typing import Any from fastapi import BackgroundTasks, FastAPI, HTTPException, Request from fastapi.responses import JSONResponse from pydantic import ValidationError from app.config import Settings, get_settings from app.models import SignalPayload from app.pipeline import deliver_telegram logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s", ) logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(_app: FastAPI): settings = get_settings() task: asyncio.Task[None] | None = None if settings.scanner_enabled: from app.scanner import run_scanner task = asyncio.create_task(run_scanner(settings)) logger.info( "Scanner enabled watchlist=%s poll=%ss", settings.watchlist_path, settings.scanner_poll_seconds, ) yield if task is not None: task.cancel() try: await task except asyncio.CancelledError: pass logger.info("Scanner stopped") app = FastAPI( title="TV Signals → Telegram", version="1.0.0", docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan, ) def _parse_signal_body(raw: bytes) -> SignalPayload: """Parse JSON from raw body (works for application/json and text/plain).""" try: text = raw.decode("utf-8").strip() except UnicodeDecodeError as exc: raise HTTPException(status_code=422, detail="Body must be UTF-8 text") from exc if not text: raise HTTPException(status_code=422, detail="Empty body") try: data: Any = json.loads(text) except json.JSONDecodeError as exc: raise HTTPException(status_code=422, detail=f"Invalid JSON: {exc}") from exc try: return SignalPayload.model_validate(data) except ValidationError as exc: raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc @app.api_route("/health", methods=["GET", "HEAD"]) async def health() -> dict[str, str]: return {"status": "ok"} async def _handle_webhook( token: str, request: Request, thread_id: int | None, background_tasks: BackgroundTasks, ) -> JSONResponse: settings = get_settings() if not secrets.compare_digest(token, settings.webhook_secret): raise HTTPException(status_code=404, detail="Not Found") message_thread_id = ( thread_id if thread_id is not None else settings.telegram_message_thread_id ) signal = _parse_signal_body(await request.body()) logger.info( "Signal accepted: %s %s seq=%s tf=%s thread=%s", signal.ticker, signal.action.value, signal.signal_sequence, signal.visual_timeframe, message_thread_id, ) background_tasks.add_task( deliver_telegram, settings, signal, message_thread_id, ) return JSONResponse({"ok": True, "accepted": True}, status_code=200) @app.post("/h/{token}") async def webhook( token: str, request: Request, background_tasks: BackgroundTasks, ) -> JSONResponse: return await _handle_webhook( token, request, thread_id=None, background_tasks=background_tasks ) @app.post("/h/{token}/{thread_id}") async def webhook_with_thread( token: str, thread_id: int, request: Request, background_tasks: BackgroundTasks, ) -> JSONResponse: if thread_id < 1: raise HTTPException(status_code=422, detail="thread_id must be >= 1") return await _handle_webhook( token, request, thread_id=thread_id, background_tasks=background_tasks )