from __future__ import annotations import json import logging import secrets from typing import Any from fastapi import FastAPI, HTTPException, Request from fastapi.responses import JSONResponse from pydantic import ValidationError from app.binance import fetch_klines, to_binance_interval, to_binance_symbol from app.chart import render_setup_chart from app.config import get_settings from app.formatter import format_caption from app.models import SignalPayload from app.telegram import TelegramError, send_message, send_photo logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s", ) logger = logging.getLogger(__name__) app = FastAPI( title="TV Signals → Telegram", version="1.0.0", docs_url=None, redoc_url=None, openapi_url=None, ) 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"} @app.post("/h/{token}") async def webhook(token: str, request: Request) -> JSONResponse: settings = get_settings() if not secrets.compare_digest(token, settings.webhook_secret): raise HTTPException(status_code=404, detail="Not Found") signal = _parse_signal_body(await request.body()) caption = format_caption(signal) logger.info( "Signal received: %s %s seq=%s tf=%s", signal.ticker, signal.action.value, signal.signal_sequence, signal.visual_timeframe, ) photo: bytes | None = None chart_error: str | None = None try: symbol = to_binance_symbol(signal.ticker) interval = to_binance_interval(signal.visual_timeframe) df = await fetch_klines(symbol, interval) photo = render_setup_chart( df, ticker=signal.ticker, action=signal.action.value, # type: ignore[arg-type] entry=signal.entry_price, stop_loss=signal.stop_loss_price, tp1=signal.take_profit_1_price, tp2=signal.take_profit_2_price, tp3=signal.take_profit_3_price, timeframe=signal.visual_timeframe, current_price=( signal.current_price if signal.signal_sequence > 1 else None ), signal_time=signal.signal_time, ) except Exception as exc: # noqa: BLE001 — fallback to text-only post chart_error = str(exc) logger.exception("Chart generation failed, falling back to text-only: %s", exc) try: if photo is not None: await send_photo(settings, photo=photo, caption=caption) return JSONResponse( {"ok": True, "delivered": "photo", "chart_error": None}, status_code=200, ) await send_message(settings, text=caption) return JSONResponse( { "ok": True, "delivered": "text", "chart_error": chart_error, }, status_code=200, ) except TelegramError as exc: logger.exception("Telegram delivery failed: %s", exc) raise HTTPException(status_code=502, detail=str(exc)) from exc except Exception as exc: # noqa: BLE001 logger.exception("Unexpected delivery error: %s", exc) raise HTTPException(status_code=500, detail=str(exc)) from exc