"""Analysis-first Telegram webhook adapter for the existing Futures pipeline. This module deliberately contains no order path. It translates authorized Telegram updates into calls to ``run_futures_cycle(..., execute=False)`` and returns compact Bot API responses. Direct delivery is best-effort; webhook responses, a configured proxy, or an authenticated relay can be used without bringing Telegram networking into the Futures engine. """ from __future__ import annotations import hashlib import hmac import html import json import os import re import secrets import threading import time from pathlib import Path from typing import Any import httpx from fastapi import APIRouter, Request from fastapi.responses import JSONResponse from trading.futures_execution import get_futures_account, get_futures_positions from trading.trade_cycle import run_futures_cycle from trading.symbols import normalize_symbol from tools.futures_dashboard_api import _load_symbols from trading.dual_datasource_client import DS4_BASE, DS2_BASE router = APIRouter() _MAX_WEBHOOK_BYTES = 256 * 1024 _STORE_PATH = Path(os.environ.get("TELEGRAM_STATE_PATH", "/opt/data/telegram_state.json")) _LOCK = threading.Lock() _RATE: dict[str, list[float]] = {} _NONCES: dict[str, dict[str, Any]] = {} _LAST: dict[str, Any] = {"received": None, "reply": None, "error": None} def _enabled() -> bool: return os.environ.get("TELEGRAM_ENABLED", "false").lower() in {"1", "true", "yes", "on"} def _allowed_ids() -> set[str]: return {x.strip() for x in os.environ.get("TELEGRAM_ALLOWED_USER_IDS", "").split(",") if x.strip()} def _load_store() -> dict: try: data = json.loads(_STORE_PATH.read_text(encoding="utf-8")) return data if isinstance(data, dict) else {} except (OSError, ValueError, TypeError): return {} def _save_store(data: dict) -> None: try: _STORE_PATH.parent.mkdir(parents=True, exist_ok=True) tmp = _STORE_PATH.with_suffix(".tmp") tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") tmp.replace(_STORE_PATH) except OSError: pass def _user(user_id: str) -> dict: with _LOCK: data = _load_store() users = data.setdefault("users", {}) user = users.setdefault(user_id, {"watchlist": [], "settings": {"alerts": False, "direction": "both", "min_score": 0, "cooldown": 900}}) _save_store(data) return user def _authorized(user_id: str) -> bool: if not user_id: return False try: persisted = _load_store().get("owner_user_id") except Exception: persisted = None return user_id in _allowed_ids() or str(persisted or "") == user_id def _claim_owner(user_id: str, secret: str, *, private_chat: bool) -> bool: """Atomically consume the one-time bootstrap secret.""" if not private_chat or not user_id or not secret: return False expected = os.environ.get("TELEGRAM_BOOTSTRAP_SECRET", "").strip() if not expected: return False with _LOCK: data = _load_store() if data.get("bootstrap_consumed") or data.get("owner_user_id"): return False if not hmac.compare_digest(secret, expected): return False data["owner_user_id"] = user_id data["bootstrap_consumed"] = True _save_store(data) # Prevent reuse in this process; persistent bootstrap_consumed protects # restarts. The HF Secret can be removed separately by an operator. os.environ.pop("TELEGRAM_BOOTSTRAP_SECRET", None) return True def _rate_ok(user_id: str) -> bool: now = time.time(); window = 60.0 values = [x for x in _RATE.get(user_id, []) if now - x < window] limit = max(1, int(os.environ.get("TELEGRAM_COMMAND_RATE_LIMIT", "10"))) if len(values) >= limit: _RATE[user_id] = values return False values.append(now); _RATE[user_id] = values return True def _esc(value: Any) -> str: return html.escape(str(value if value not in (None, "") else "—"), quote=False) def _keyboard(user_id: str = "") -> dict: nonce = _nonce(user_id) def cb(name: str) -> str: return f"menu:{name}|{nonce}" return {"inline_keyboard": [ [{"text": "Promising Coins", "callback_data": cb("scan")}, {"text": "Analyze Symbol", "callback_data": cb("analyze")}], [{"text": "Current Signals", "callback_data": cb("signals")}, {"text": "Market Overview", "callback_data": cb("market")}], [{"text": "Watchlist", "callback_data": cb("watchlist")}, {"text": "Alerts", "callback_data": cb("alerts")}], [{"text": "Paper Account", "callback_data": cb("account")}, {"text": "Datasource Health", "callback_data": cb("sources")}], [{"text": "Settings", "callback_data": cb("settings")}], ]} def _response(chat_id: Any, text: str, *, keyboard: dict | None = None) -> dict: result = {"method": "sendMessage", "chat_id": chat_id, "text": text[:4096], "parse_mode": "HTML"} if keyboard: result["reply_markup"] = keyboard return result def _nonce(user_id: str) -> str: value = secrets.token_urlsafe(12) _NONCES[value] = {"expires": time.time() + 300, "user": user_id} return value async def _direct_send(method: str, payload: dict) -> bool: token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip() if not token: return False proxy = os.environ.get("TELEGRAM_PROXY_URL", "").strip() or None url = f"https://api.telegram.org/bot{token}/{method}" try: async with httpx.AsyncClient(proxy=proxy, timeout=httpx.Timeout(5.0, connect=3.0)) as client: response = await client.post(url, json=payload) return response.status_code < 400 except Exception: return False async def _relay_send(method: str, payload: dict) -> bool: relay = os.environ.get("TELEGRAM_RELAY_URL", "").strip() secret = os.environ.get("TELEGRAM_RELAY_SECRET", "").strip() if not relay or not secret: return False body = json.dumps({"method": method, "payload": payload}, separators=(",", ":")).encode() signature = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() try: async with httpx.AsyncClient(timeout=5.0) as client: response = await client.post(relay, content=body, headers={"X-Relay-Signature": signature, "Content-Type": "application/json"}) return response.status_code < 400 except Exception: return False async def deliver(method: str, payload: dict) -> bool: return await _relay_send(method, payload) or await _direct_send(method, payload) async def _scan(user_id: str) -> str: items, _ = await _load_symbols() max_symbols = min(300, max(1, int(os.environ.get("TELEGRAM_SCAN_MAX_SYMBOLS", "300")))) shortlist_size = max(1, int(os.environ.get("TELEGRAM_SCAN_SHORTLIST_SIZE", "20"))) result_count = max(1, int(os.environ.get("TELEGRAM_SCAN_RESULT_COUNT", "10"))) candidates = [x for x in items[:max_symbols] if x.get("futuresVerified")] # Stage 1 is intentionally cheap and deterministic: verified contracts are # preferred, then catalog rank when supplied. No advisory calls occur here. candidates.sort(key=lambda x: (x.get("rank") is None, x.get("rank") or 999999, x.get("symbol", ""))) import asyncio sem = asyncio.Semaphore(max(1, int(os.environ.get("TELEGRAM_SCAN_MAX_CONCURRENCY", "4")))) async def one(item: dict) -> dict: async with sem: try: plan = await run_futures_cycle(item["symbol"], risk_profile="moderate", execute=False, include_external_context=False) return {"symbol": item["symbol"], "decision": plan.get("decision", "NO_TRADE"), "score": plan.get("score"), "verified": True, "reason": (plan.get("rejection_reasons") or plan.get("core_reasons") or [""])[0]} except Exception as exc: return {"symbol": item["symbol"], "decision": "NO_TRADE", "verified": True, "reason": "analysis unavailable"} rows = await asyncio.gather(*(one(x) for x in candidates[:shortlist_size])) rows.sort(key=lambda x: (x.get("score") is None, -(float(x.get("score") or 0)))) lines = ["Promising Coins", f"Evaluated {len(candidates[:max_symbols])} verified contracts; shortlist {len(rows)}."] for row in rows[:result_count]: lines.append(f"{_esc(row['symbol'])} · {_esc(row['decision'])} · score {_esc(row.get('score'))}") return "\n".join(lines) async def _analysis(symbol: str) -> str: try: norm = normalize_symbol(symbol).ds4 items, _ = await _load_symbols() verified = next((x for x in items if x.get("symbol") == norm and x.get("futuresVerified")), None) if not verified: return f"{_esc(norm)} · Market-only / unverified\nDecision: NO_TRADE\nPaper analysis requires a verified Futures contract." plan = await run_futures_cycle(norm, risk_profile="moderate", execute=False, include_external_context=True) advisory = plan.get("external_advisory") or {} lines = [f"{_esc(norm)} · {'Verified Futures' if verified else 'Market-only / unverified'}", f"Decision: {_esc(plan.get('decision'))} · Score: {_esc(plan.get('score'))}", f"Entry {_esc(plan.get('entry'))} · SL {_esc(plan.get('stop_loss'))} · TP {_esc(plan.get('take_profit'))}", f"Leverage {_esc(plan.get('effective_leverage') or plan.get('requested_leverage'))} · Quantity {_esc(plan.get('quantity'))}", f"Risk approval: {_esc(plan.get('risk_approved'))}"] reasons = list(dict.fromkeys((plan.get("rejection_reasons") or []) + (plan.get("core_reasons") or []))) if reasons: lines.append("Reasons: " + "; ".join(_esc(x) for x in reasons[:3])) if advisory: lines.append(f"Advisory: {_esc(advisory.get('provider'))} · {_esc(advisory.get('model'))} · {_esc(advisory.get('market_bias'))}\n{_esc(advisory.get('summary'))}") return "\n".join(lines)[:4096] except Exception: return "Analysis unavailable; deterministic safety state was preserved." async def _sources() -> str: async with httpx.AsyncClient(timeout=4.0) as client: async def probe(url: str) -> str: try: response = await client.get(url); return "ok" if response.status_code < 400 else f"degraded ({response.status_code})" except Exception: return "unavailable" ds4, ds2 = await __import__("asyncio").gather(probe(f"{DS4_BASE}/api/short-hunter/health"), probe(f"{DS2_BASE}/api/market")) return f"Datasource Health\nDS4 authoritative: {_esc(ds4)}\nDS2 complementary: {_esc(ds2)}\nBinance: optional market fallback (regional HTTP 451 may apply)." async def _command(user_id: str, chat_id: Any, text: str) -> dict: parts = text.strip().split(maxsplit=1); command = parts[0].lower().split("@", 1)[0] if parts else "/help"; arg = parts[1].strip() if len(parts) > 1 else "" if command in {"/start", "/help", "/menu"}: return _response(chat_id, "Hermes Futures Desk\nAnalysis-only Telegram access. Paper/Testnet/Live execution is not available here.", keyboard=_keyboard(user_id)) if command in {"/scan", "/top"}: return _response(chat_id, await _scan(user_id), keyboard=_keyboard(user_id)) if command in {"/analyze", "/signal", "/market"}: return _response(chat_id, await _analysis(arg or "BTCUSDT"), keyboard=_keyboard(user_id)) user = _user(user_id) if command == "/watch": try: symbol = normalize_symbol(arg).ds4 except ValueError: return _response(chat_id, "Use a valid symbol such as BTCUSDT.") if symbol not in user["watchlist"]: user["watchlist"].append(symbol); _user_save(user_id, user) return _response(chat_id, f"Watching {_esc(symbol)}.") if command == "/unwatch": try: symbol = normalize_symbol(arg).ds4 if arg else "" except ValueError: return _response(chat_id, "Use a valid symbol such as BTCUSDT.") user["watchlist"] = [x for x in user["watchlist"] if x != symbol]; _user_save(user_id, user); return _response(chat_id, "Watchlist updated.") if command == "/watchlist": return _response(chat_id, "Watchlist\n" + ("\n".join(_esc(x) for x in user["watchlist"]) or "Empty.")) if command == "/alerts": setting = arg.lower().split() if setting and setting[0] in {"on", "off"}: user["settings"]["alerts"] = setting[0] == "on"; _user_save(user_id, user) elif len(setting) >= 1 and setting[0] in {"long", "short", "both"}: user["settings"]["direction"] = setting[0]; _user_save(user_id, user) elif len(setting) >= 2 and setting[0] == "score": try: user["settings"]["min_score"] = float(setting[1]); _user_save(user_id, user) except ValueError: pass elif len(setting) >= 2 and setting[0] == "cooldown": try: user["settings"]["cooldown"] = max(60, int(setting[1])); _user_save(user_id, user) except ValueError: pass return _response(chat_id, f"Alerts: {'enabled' if user['settings'].get('alerts') else 'disabled'} · direction {user['settings'].get('direction','both')} · min score {user['settings'].get('min_score',0)} · cooldown {user['settings'].get('cooldown',900)}s") if command == "/account": return _response(chat_id, _format_account(await get_futures_account())) if command == "/positions": return _response(chat_id, _format_positions(await get_futures_positions())) if command == "/sources" or command == "/health": return _response(chat_id, await _sources()) if command in {"/risk", "/settings"}: return _response(chat_id, "Use the authenticated Futures dashboard for this read-only status panel.") return _response(chat_id, "Unknown command. Use /help.") def _user_save(user_id: str, user: dict) -> None: with _LOCK: data = _load_store(); data.setdefault("users", {})[user_id] = user; _save_store(data) def _format_account(account: dict) -> str: return f"Paper Account\nMode: {_esc(account.get('mode'))}\nEquity: {_esc(account.get('equity'))}\nDaily PnL: {_esc(account.get('realizedPnlToday'))}" def _format_positions(data: dict) -> str: rows = data.get("positions") or []; return "Paper Positions\n" + ("\n".join(f"{_esc(x.get('symbol'))} · {_esc(x.get('side'))} · size {_esc(x.get('size'))}" for x in rows) or "No open positions.") async def evaluate_alerts(user_id: str) -> list[dict]: """Evaluate a user's watchlist with cooldown and state-change suppression. Delivery is optional; failures are returned as unavailable rather than retried in a loop. This function is safe to call from an external scheduler. """ user = _user(user_id); settings = user.get("settings", {}) if not settings.get("alerts"): return [] data = _load_store(); states = data.setdefault("alert_state", {}); now = time.time(); results = [] for symbol in list(user.get("watchlist", [])): try: plan = await run_futures_cycle(symbol, risk_profile="moderate", execute=False, include_external_context=False) except Exception: continue decision = str(plan.get("decision") or "NO_TRADE"); score = float(plan.get("score") or 0) direction = str(settings.get("direction", "both")) if decision not in {"LONG", "SHORT"} or (direction == "long" and decision != "LONG") or (direction == "short" and decision != "SHORT") or score < float(settings.get("min_score", 0)): continue key = f"{user_id}:{symbol}"; previous = states.get(key) or {} if previous.get("decision") == decision and now - float(previous.get("sentAt", 0)) < int(settings.get("cooldown", 900)): continue payload = {"chat_id": user_id, "text": f"Watchlist alert\n{_esc(symbol)} · {decision} · score {_esc(score)}", "parse_mode": "HTML"} delivered = await deliver("sendMessage", payload) states[key] = {"decision": decision, "score": score, "sentAt": now, "delivery": "ok" if delivered else "unavailable"} results.append({"symbol": symbol, "decision": decision, "delivery": "ok" if delivered else "unavailable"}) _save_store(data) return results @router.post("/api/telegram/webhook") async def telegram_webhook(request: Request) -> JSONResponse: if not _enabled(): return JSONResponse({"ok": False, "detail": "Telegram disabled"}, status_code=404) expected = os.environ.get("TELEGRAM_WEBHOOK_SECRET", "").strip() supplied = request.headers.get("X-Telegram-Bot-Api-Secret-Token", "") if not expected or not hmac.compare_digest(supplied, expected): return JSONResponse({"ok": False}, status_code=401) try: declared_length = int(request.headers.get("content-length", "0") or 0) if declared_length > _MAX_WEBHOOK_BYTES: return JSONResponse({"ok": False, "detail": "request too large"}, status_code=413) raw_body = await request.body() if len(raw_body) > _MAX_WEBHOOK_BYTES: return JSONResponse({"ok": False, "detail": "request too large"}, status_code=413) update = json.loads(raw_body) except Exception: return JSONResponse({"ok": False}, status_code=400) message = update.get("message") or update.get("edited_message") callback = update.get("callback_query") user = (message or callback or {}).get("from") or {}; user_id = str(user.get("id", "")); chat_id = ((message or {}).get("chat") or {}).get("id") or (((callback or {}).get("message") or {}).get("chat") or {}).get("id") if not _rate_ok(user_id): return JSONResponse({"ok": False, "detail": "rate limited"}, status_code=429) _LAST.update(received=time.time(), error=None) text = (message or {}).get("text") or "" if text.strip().lower().startswith("/claim "): private_chat = str(((message or {}).get("chat") or {}).get("type") or "") == "private" claim_secret = text.strip().split(None, 1)[1] if _claim_owner(user_id, claim_secret, private_chat=private_chat): return JSONResponse(_response(chat_id, "Owner claimed. Use /start to open the Futures menu.")) return JSONResponse({"ok": False}, status_code=403) if not _authorized(user_id): return JSONResponse({"ok": False}, status_code=403) if callback: raw_callback = str(callback.get("data") or "") callback_name, _, callback_nonce = raw_callback.partition("|") nonce_info = _NONCES.get(callback_nonce) or {} if not callback_nonce or nonce_info.get("expires", 0.0) < time.time() or nonce_info.get("user") != user_id: return JSONResponse({"ok": False, "detail": "expired or unauthorized callback"}, status_code=409) _NONCES.pop(callback_nonce, None) text = "/" + callback_name.replace("menu:", "") # Acknowledge the callback so the Telegram client clears the button's # loading spinner. This is best-effort and must not block the reply. callback_id = callback.get("id") if callback_id: await deliver("answerCallbackQuery", {"callback_query_id": callback_id}) reply = await _command(user_id, chat_id, text) _LAST["reply"] = time.time() return JSONResponse(reply) @router.get("/api/telegram/status") async def telegram_status() -> JSONResponse: return JSONResponse({"enabled": _enabled(), "mode": os.environ.get("TELEGRAM_MODE", "webhook"), "webhookEndpointReady": _enabled() and bool(os.environ.get("TELEGRAM_WEBHOOK_SECRET")), "proxyConfigured": bool(os.environ.get("TELEGRAM_PROXY_URL")), "relayConfigured": bool(os.environ.get("TELEGRAM_RELAY_URL") and os.environ.get("TELEGRAM_RELAY_SECRET")), "lastReceivedUpdate": _LAST.get("received"), "lastSuccessfulReply": _LAST.get("reply"), "authorizedUserCount": len(_allowed_ids()), "alertScheduler": "enabled" if os.environ.get("TELEGRAM_ALERTS_ENABLED", "false").lower() == "true" else "disabled", "lastError": _LAST.get("error")}) @router.get("/api/telegram/bootstrap/status") async def telegram_bootstrap_status(request: Request) -> JSONResponse: """Return claim state to the activation workstation without exposing owner data.""" expected = os.environ.get("TELEGRAM_WEBHOOK_SECRET", "").strip() supplied = request.headers.get("X-Telegram-Bot-Api-Secret-Token", "") if not expected or not hmac.compare_digest(supplied, expected): return JSONResponse({"ok": False}, status_code=401) data = _load_store() return JSONResponse({"ok": True, "ownerClaimed": bool(data.get("owner_user_id")), "bootstrapConsumed": bool(data.get("bootstrap_consumed"))})