From 15efda5e0d04cab30f04c528b69fdce41b1e89a0 Mon Sep 17 00:00:00 2001 From: Artemii Peretiachenko Date: Fri, 24 Jul 2026 20:22:55 +0200 Subject: [PATCH] Allow forum topic id in webhook URL path. TradingView alerts can target different topics via /h/{secret}/{thread_id} while keeping the same payload; bare /h/{secret} still uses the env default. Co-authored-by: Cursor --- .dockerignore | 11 +++++++++++ .env.example | 1 + README.md | 13 ++++++++++--- app/main.py | 43 ++++++++++++++++++++++++++++++++++++++----- app/telegram.py | 6 ++++-- docker-compose.yml | 22 +++++++++++++++++++++- 6 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d7617bc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.venv +venv +__pycache__ +*.pyc +.env +.env.* +!.env.example +*.md +.DS_Store +.cursor diff --git a/.env.example b/.env.example index 89c4729..62e6322 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ TELEGRAM_BOT_TOKEN=123456:ABC-DEF TELEGRAM_CHAT_ID=-1001234567890 +# Default forum topic when webhook URL has no /{thread_id} segment TELEGRAM_MESSAGE_THREAD_ID=1 WEBHOOK_SECRET=change-me-to-a-long-random-string HOST=0.0.0.0 diff --git a/README.md b/README.md index 06f01ea..15c9a62 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ curl http://127.0.0.1:8000/health `https://your-domain/h/` +or, for a specific forum topic: + +`https://your-domain/h//` + +Same alert message body; different URLs → different topics (e.g. one alert per timeframe). Without a thread segment, the env `TELEGRAM_MESSAGE_THREAD_ID` is used. + Bot must be added to the group/forum and allowed to post in the target topic. ## Local run (without Docker) @@ -92,7 +98,7 @@ Build the JSON inside `alert()`. A continuous one-line string is fine. In the TradingView alert dialog: -- Webhook URL: `https://your-domain/h/` +- Webhook URL: `https://your-domain/h/` or `https://your-domain/h//` (per-topic; same message body) - Message: only `{{alert_message}}` (do not paste a second JSON next to it) On **seq == 1**: store `entry_price = close`, freeze SL/TPs, and `signal_time = time / 1000` (bar open, unix seconds). On **seq > 1**: keep those frozen fields; only refresh `current_price` (= live `close`). Example shape: @@ -140,11 +146,12 @@ For seq >1, profit % is signed vs entry; RR is `|price−entry| / |entry−SL|` 1. Validate payload 2. Fetch ~90 klines from Binance USDT-M Futures (public, no API key) 3. Render PNG: candles + Entry / SL / TP1–3 from the payload, starting at the `signal_time` candle (seq `>1` reuses frozen seq-1 levels/time and also marks live `Price`) -4. `sendPhoto` to `TELEGRAM_CHAT_ID` topic `TELEGRAM_MESSAGE_THREAD_ID` +4. `sendPhoto` to `TELEGRAM_CHAT_ID` topic from URL path or env `TELEGRAM_MESSAGE_THREAD_ID` 5. If chart/klines fail → text-only `sendMessage` fallback (still `200`) 6. If Telegram fails → `502` ## Endpoints - `GET /health` → `{"status":"ok"}` -- `POST /h/` → signal payload above (wrong/missing secret → `404`) +- `POST /h/` → signal payload above; topic from env `TELEGRAM_MESSAGE_THREAD_ID` (wrong/missing secret → `404`) +- `POST /h//` → same payload; topic from path (`thread_id` must be `>= 1`) diff --git a/app/main.py b/app/main.py index e3f9801..97edaa0 100644 --- a/app/main.py +++ b/app/main.py @@ -57,20 +57,28 @@ async def health() -> dict[str, str]: return {"status": "ok"} -@app.post("/h/{token}") -async def webhook(token: str, request: Request) -> JSONResponse: +async def _handle_webhook( + token: str, + request: Request, + thread_id: int | None, +) -> 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()) caption = format_caption(signal) logger.info( - "Signal received: %s %s seq=%s tf=%s", + "Signal received: %s %s seq=%s tf=%s thread=%s", signal.ticker, signal.action.value, signal.signal_sequence, signal.visual_timeframe, + message_thread_id, ) photo: bytes | None = None @@ -101,13 +109,22 @@ async def webhook(token: str, request: Request) -> JSONResponse: try: if photo is not None: - await send_photo(settings, photo=photo, caption=caption) + await send_photo( + settings, + photo=photo, + caption=caption, + message_thread_id=message_thread_id, + ) return JSONResponse( {"ok": True, "delivered": "photo", "chart_error": None}, status_code=200, ) - await send_message(settings, text=caption) + await send_message( + settings, + text=caption, + message_thread_id=message_thread_id, + ) return JSONResponse( { "ok": True, @@ -122,3 +139,19 @@ async def webhook(token: str, request: Request) -> JSONResponse: except Exception as exc: # noqa: BLE001 logger.exception("Unexpected delivery error: %s", exc) raise HTTPException(status_code=500, detail=str(exc)) from exc + + +@app.post("/h/{token}") +async def webhook(token: str, request: Request) -> JSONResponse: + return await _handle_webhook(token, request, thread_id=None) + + +@app.post("/h/{token}/{thread_id}") +async def webhook_with_thread( + token: str, + thread_id: int, + request: Request, +) -> 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) diff --git a/app/telegram.py b/app/telegram.py index 4259ed2..3a073b2 100644 --- a/app/telegram.py +++ b/app/telegram.py @@ -20,12 +20,13 @@ async def send_photo( *, photo: bytes, caption: str, + message_thread_id: int, filename: str = "setup.png", ) -> dict: url = f"{TELEGRAM_API}/bot{settings.telegram_bot_token}/sendPhoto" data = { "chat_id": settings.telegram_chat_id, - "message_thread_id": str(settings.telegram_message_thread_id), + "message_thread_id": str(message_thread_id), "caption": caption, "parse_mode": "HTML", } @@ -42,11 +43,12 @@ async def send_message( settings: Settings, *, text: str, + message_thread_id: int, ) -> dict: url = f"{TELEGRAM_API}/bot{settings.telegram_bot_token}/sendMessage" data = { "chat_id": settings.telegram_chat_id, - "message_thread_id": str(settings.telegram_message_thread_id), + "message_thread_id": str(message_thread_id), "text": text, "parse_mode": "HTML", "disable_web_page_preview": True, diff --git a/docker-compose.yml b/docker-compose.yml index 6cfbda4..58cb1c5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,27 @@ services: tvsignals: build: . ports: - - "${PORT:-8000}:8000" + - "8000:8000" env_file: - .env + environment: + HOST: "0.0.0.0" + PORT: "8000" + MPLBACKEND: Agg restart: unless-stopped + networks: + default: + geryon: + aliases: + - tvsignals + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + +networks: + geryon: + external: true + name: geryon-prod_geryon-prod