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 <cursoragent@cursor.com>
This commit is contained in:
Artemii Peretiachenko 2026-07-24 20:22:55 +02:00
parent c8b5eb741f
commit 15efda5e0d
6 changed files with 85 additions and 11 deletions

11
.dockerignore Normal file
View file

@ -0,0 +1,11 @@
.git
.venv
venv
__pycache__
*.pyc
.env
.env.*
!.env.example
*.md
.DS_Store
.cursor

View file

@ -1,5 +1,6 @@
TELEGRAM_BOT_TOKEN=123456:ABC-DEF TELEGRAM_BOT_TOKEN=123456:ABC-DEF
TELEGRAM_CHAT_ID=-1001234567890 TELEGRAM_CHAT_ID=-1001234567890
# Default forum topic when webhook URL has no /{thread_id} segment
TELEGRAM_MESSAGE_THREAD_ID=1 TELEGRAM_MESSAGE_THREAD_ID=1
WEBHOOK_SECRET=change-me-to-a-long-random-string WEBHOOK_SECRET=change-me-to-a-long-random-string
HOST=0.0.0.0 HOST=0.0.0.0

View file

@ -34,6 +34,12 @@ curl http://127.0.0.1:8000/health
`https://your-domain/h/<WEBHOOK_SECRET>` `https://your-domain/h/<WEBHOOK_SECRET>`
or, for a specific forum topic:
`https://your-domain/h/<WEBHOOK_SECRET>/<TELEGRAM_MESSAGE_THREAD_ID>`
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. Bot must be added to the group/forum and allowed to post in the target topic.
## Local run (without Docker) ## 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: In the TradingView alert dialog:
- Webhook URL: `https://your-domain/h/<WEBHOOK_SECRET>` - Webhook URL: `https://your-domain/h/<WEBHOOK_SECRET>` or `https://your-domain/h/<WEBHOOK_SECRET>/<thread_id>` (per-topic; same message body)
- Message: only `{{alert_message}}` (do not paste a second JSON next to it) - 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: 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 `|priceentry| / |entrySL|`
1. Validate payload 1. Validate payload
2. Fetch ~90 klines from Binance USDT-M Futures (public, no API key) 2. Fetch ~90 klines from Binance USDT-M Futures (public, no API key)
3. Render PNG: candles + Entry / SL / TP13 from the payload, starting at the `signal_time` candle (seq `>1` reuses frozen seq-1 levels/time and also marks live `Price`) 3. Render PNG: candles + Entry / SL / TP13 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`) 5. If chart/klines fail → text-only `sendMessage` fallback (still `200`)
6. If Telegram fails → `502` 6. If Telegram fails → `502`
## Endpoints ## Endpoints
- `GET /health``{"status":"ok"}` - `GET /health``{"status":"ok"}`
- `POST /h/<WEBHOOK_SECRET>` → signal payload above (wrong/missing secret → `404`) - `POST /h/<WEBHOOK_SECRET>` → signal payload above; topic from env `TELEGRAM_MESSAGE_THREAD_ID` (wrong/missing secret → `404`)
- `POST /h/<WEBHOOK_SECRET>/<thread_id>` → same payload; topic from path (`thread_id` must be `>= 1`)

View file

@ -57,20 +57,28 @@ async def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
@app.post("/h/{token}") async def _handle_webhook(
async def webhook(token: str, request: Request) -> JSONResponse: token: str,
request: Request,
thread_id: int | None,
) -> JSONResponse:
settings = get_settings() settings = get_settings()
if not secrets.compare_digest(token, settings.webhook_secret): if not secrets.compare_digest(token, settings.webhook_secret):
raise HTTPException(status_code=404, detail="Not Found") 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()) signal = _parse_signal_body(await request.body())
caption = format_caption(signal) caption = format_caption(signal)
logger.info( logger.info(
"Signal received: %s %s seq=%s tf=%s", "Signal received: %s %s seq=%s tf=%s thread=%s",
signal.ticker, signal.ticker,
signal.action.value, signal.action.value,
signal.signal_sequence, signal.signal_sequence,
signal.visual_timeframe, signal.visual_timeframe,
message_thread_id,
) )
photo: bytes | None = None photo: bytes | None = None
@ -101,13 +109,22 @@ async def webhook(token: str, request: Request) -> JSONResponse:
try: try:
if photo is not None: 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( return JSONResponse(
{"ok": True, "delivered": "photo", "chart_error": None}, {"ok": True, "delivered": "photo", "chart_error": None},
status_code=200, status_code=200,
) )
await send_message(settings, text=caption) await send_message(
settings,
text=caption,
message_thread_id=message_thread_id,
)
return JSONResponse( return JSONResponse(
{ {
"ok": True, "ok": True,
@ -122,3 +139,19 @@ async def webhook(token: str, request: Request) -> JSONResponse:
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.exception("Unexpected delivery error: %s", exc) logger.exception("Unexpected delivery error: %s", exc)
raise HTTPException(status_code=500, detail=str(exc)) from 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)

View file

@ -20,12 +20,13 @@ async def send_photo(
*, *,
photo: bytes, photo: bytes,
caption: str, caption: str,
message_thread_id: int,
filename: str = "setup.png", filename: str = "setup.png",
) -> dict: ) -> dict:
url = f"{TELEGRAM_API}/bot{settings.telegram_bot_token}/sendPhoto" url = f"{TELEGRAM_API}/bot{settings.telegram_bot_token}/sendPhoto"
data = { data = {
"chat_id": settings.telegram_chat_id, "chat_id": settings.telegram_chat_id,
"message_thread_id": str(settings.telegram_message_thread_id), "message_thread_id": str(message_thread_id),
"caption": caption, "caption": caption,
"parse_mode": "HTML", "parse_mode": "HTML",
} }
@ -42,11 +43,12 @@ async def send_message(
settings: Settings, settings: Settings,
*, *,
text: str, text: str,
message_thread_id: int,
) -> dict: ) -> dict:
url = f"{TELEGRAM_API}/bot{settings.telegram_bot_token}/sendMessage" url = f"{TELEGRAM_API}/bot{settings.telegram_bot_token}/sendMessage"
data = { data = {
"chat_id": settings.telegram_chat_id, "chat_id": settings.telegram_chat_id,
"message_thread_id": str(settings.telegram_message_thread_id), "message_thread_id": str(message_thread_id),
"text": text, "text": text,
"parse_mode": "HTML", "parse_mode": "HTML",
"disable_web_page_preview": True, "disable_web_page_preview": True,

View file

@ -2,7 +2,27 @@ services:
tvsignals: tvsignals:
build: . build: .
ports: ports:
- "${PORT:-8000}:8000" - "8000:8000"
env_file: env_file:
- .env - .env
environment:
HOST: "0.0.0.0"
PORT: "8000"
MPLBACKEND: Agg
restart: unless-stopped 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