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_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

View file

@ -34,6 +34,12 @@ curl http://127.0.0.1:8000/health
`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.
## 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_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)
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
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`)
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/<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"}
@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)

View file

@ -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,

View file

@ -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