mirror of
https://github.com/artemium428/tvsignals-to-tg.git
synced 2026-09-15 17:16:21 +00:00
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:
parent
c8b5eb741f
commit
15efda5e0d
6 changed files with 85 additions and 11 deletions
11
.dockerignore
Normal file
11
.dockerignore
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.git
|
||||
.venv
|
||||
venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.md
|
||||
.DS_Store
|
||||
.cursor
|
||||
|
|
@ -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
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -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 `|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/<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`)
|
||||
|
|
|
|||
43
app/main.py
43
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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue